From 13a577a731bebf03441f330acdf06db2d0b34977 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:05:08 +0200 Subject: [PATCH 1/8] fix: align resource access checks --- .../Controllers/Api/ProjectController.php | 2 + .../Controllers/Api/ServersController.php | 6 +- .../Project/Shared/ScheduledTask/Add.php | 9 +- .../Server/DockerCleanupExecutions.php | 5 +- .../Feature/ResourceAccessConsistencyTest.php | 120 ++++++++++++++++++ 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/ResourceAccessConsistencyTest.php diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index 64bf26c1bb..eb137c5349 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -158,6 +158,8 @@ class ProjectController extends Controller if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); + $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); if (! $environment) { $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index d50a5226a9..f7966c71f1 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -550,11 +550,7 @@ class ServersController extends Controller } $foundServer = ModelsServer::whereIp($request->ip)->first(); if ($foundServer) { - if ($foundServer->team_id === $teamId) { - return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400); - } - - return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400); + return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400); } $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; diff --git a/app/Livewire/Project/Shared/ScheduledTask/Add.php b/app/Livewire/Project/Shared/ScheduledTask/Add.php index 2d6b76c25f..61bc6b0fbc 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Add.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Add.php @@ -2,7 +2,10 @@ namespace App\Livewire\Project\Shared\ScheduledTask; +use App\Models\Application; use App\Models\ScheduledTask; +use App\Models\Service; +use App\Models\StandalonePostgresql; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\Locked; @@ -59,13 +62,13 @@ class Add extends Component // Get the resource based on type and id switch ($this->type) { case 'application': - $this->resource = \App\Models\Application::findOrFail($this->id); + $this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id); break; case 'service': - $this->resource = \App\Models\Service::findOrFail($this->id); + $this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id); break; case 'standalone-postgresql': - $this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); + $this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id); break; default: throw new \Exception('Invalid resource type'); diff --git a/app/Livewire/Server/DockerCleanupExecutions.php b/app/Livewire/Server/DockerCleanupExecutions.php index 56d6130644..6a739bc84c 100644 --- a/app/Livewire/Server/DockerCleanupExecutions.php +++ b/app/Livewire/Server/DockerCleanupExecutions.php @@ -2,7 +2,6 @@ namespace App\Livewire\Server; -use App\Models\DockerCleanupExecution; use App\Models\Server; use Illuminate\Support\Collection; use Livewire\Component; @@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component ->get(); if ($this->selectedKey) { - $this->selectedExecution = DockerCleanupExecution::find($this->selectedKey); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey); if ($this->selectedExecution && $this->selectedExecution->status !== 'running') { $this->isPollingActive = false; } @@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component return; } $this->selectedKey = $key; - $this->selectedExecution = DockerCleanupExecution::find($key); + $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key); $this->currentPage = 1; if ($this->selectedExecution && $this->selectedExecution->status === 'running') { diff --git a/tests/Feature/ResourceAccessConsistencyTest.php b/tests/Feature/ResourceAccessConsistencyTest.php new file mode 100644 index 0000000000..2511e14342 --- /dev/null +++ b/tests/Feature/ResourceAccessConsistencyTest.php @@ -0,0 +1,120 @@ + 'file']); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::withoutEvents(fn () => PrivateKey::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'IDOR test key', + 'private_key' => 'test-private-key', + 'team_id' => $this->team->id, + ])); + + $token = $this->user->createToken('idor-hardening', ['*']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->token = $token->plainTextToken; +}); + +test('server creation returns a consistent duplicate address response', function () { + $ownServer = Server::factory()->create([ + 'ip' => '192.0.2.10', + 'team_id' => $this->team->id, + ]); + $otherServer = Server::factory()->create([ + 'ip' => '192.0.2.20', + 'team_id' => $this->otherTeam->id, + ]); + + $payload = fn (Server $server): array => [ + 'name' => 'Duplicate server', + 'ip' => $server->ip, + 'private_key_uuid' => $this->privateKey->uuid, + 'user' => 'root', + ]; + + $ownResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($ownServer)); + $otherResponse = $this->withToken($this->token)->postJson('/api/v1/servers', $payload($otherServer)); + + $ownResponse->assertBadRequest(); + $otherResponse->assertBadRequest(); + expect($ownResponse->json('message')) + ->toBe('A server with this IP/Domain is already in use.') + ->toBe($otherResponse->json('message')); +}); + +test('environment details applies the project view policy', function () { + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + Gate::before(fn (User $user, string $ability): ?bool => $ability === 'view' ? false : null); + + $this->withToken($this->token) + ->getJson("/api/v1/projects/{$project->uuid}/{$environment->uuid}") + ->assertForbidden(); +}); + +test('docker cleanup execution selection only uses the mounted server', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $otherExecution = DockerCleanupExecution::create([ + 'server_id' => $otherServer->id, + 'status' => 'success', + 'message' => 'other team cleanup output', + ]); + + Livewire::test(DockerCleanupExecutions::class, ['server' => $server]) + ->call('selectExecution', $otherExecution->id) + ->assertSet('selectedExecution', null); +}); + +test('scheduled task form only mounts applications from the current team', function () { + $this->actingAs($this->user); + + $server = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->otherTeam->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + Livewire::test(Add::class, [ + 'id' => (string) $application->id, + 'type' => 'application', + 'containerNames' => collect(), + ]); +})->throws(ModelNotFoundException::class); From c9b857884b444f8ecb4bc44909fd973bc8b3d13f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:39:14 +0200 Subject: [PATCH 2/8] fix(api): align team-scoped responses --- app/Http/Controllers/Api/GithubController.php | 8 ++--- app/Http/Controllers/Api/GitlabController.php | 12 +++---- app/Http/Controllers/Api/TeamController.php | 16 +++++---- tests/Feature/Api/GithubAppsListApiTest.php | 33 +++++++++++++++++++ tests/Feature/Api/GitlabAppsApiTest.php | 30 +++++++++++++++++ tests/Feature/Api/TeamTokenTeamApiTest.php | 24 ++++++++++++++ 6 files changed, 106 insertions(+), 17 deletions(-) diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index 5c073e9c0a..840a11f692 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -15,9 +15,9 @@ use OpenApi\Attributes as OA; class GithubController extends Controller { - private function removeSensitiveData($githubApp) + private function removeSensitiveData(GithubApp $githubApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) { $githubApp->makeVisible([ 'client_secret', 'webhook_secret', @@ -97,8 +97,8 @@ class GithubController extends Controller ->orWhere('is_system_wide', true); })->get(); - $githubApps = $githubApps->map(function ($app) { - return $this->removeSensitiveData($app); + $githubApps = $githubApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($githubApps); diff --git a/app/Http/Controllers/Api/GitlabController.php b/app/Http/Controllers/Api/GitlabController.php index c907af46f3..959a3067aa 100644 --- a/app/Http/Controllers/Api/GitlabController.php +++ b/app/Http/Controllers/Api/GitlabController.php @@ -13,9 +13,9 @@ use OpenApi\Attributes as OA; class GitlabController extends Controller { - private function removeSensitiveData(GitlabApp $gitlabApp) + private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId) { - if (request()->attributes->get('can_read_sensitive', false) === true) { + if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) { $gitlabApp->makeVisible([ 'client_secret', 'webhook_token', @@ -108,8 +108,8 @@ class GitlabController extends Controller ->orWhere('is_system_wide', true); })->get(); - $gitlabApps = $gitlabApps->map(function ($app) { - return $this->removeSensitiveData($app); + $gitlabApps = $gitlabApps->map(function ($app) use ($teamId) { + return $this->removeSensitiveData($app, $teamId); }); return response()->json($gitlabApps); @@ -280,7 +280,7 @@ class GitlabController extends Controller 'gitlab_app_name' => $gitlabApp->name, ]); - return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); + return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201); } catch (\Throwable $e) { return handleError($e); } @@ -441,7 +441,7 @@ class GitlabController extends Controller return response()->json([ 'message' => 'GitLab app updated successfully', - 'data' => $this->removeSensitiveData($gitlabApp->fresh()), + 'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId), ]); } catch (ModelNotFoundException $e) { return response()->json([ diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index 35e01c8314..b9f8572673 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -56,7 +56,7 @@ class TeamController extends Controller if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams->sortBy('id'); + $teams = auth()->user()->teams->where('id', $teamId)->values(); $teams = $teams->map(function ($team) { return $this->removeSensitiveData($team); }); @@ -100,13 +100,14 @@ class TeamController extends Controller )] public function team_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } @@ -159,13 +160,14 @@ class TeamController extends Controller )] public function members_by_id(Request $request) { - $id = $request->id; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - $teams = auth()->user()->teams; - $team = $teams->where('id', $id)->first(); + if ((int) $request->id !== (int) $teamId) { + return response()->json(['message' => 'Team not found.'], 404); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } diff --git a/tests/Feature/Api/GithubAppsListApiTest.php b/tests/Feature/Api/GithubAppsListApiTest.php index 9a1f1f3d25..cb15cd9347 100644 --- a/tests/Feature/Api/GithubAppsListApiTest.php +++ b/tests/Feature/Api/GithubAppsListApiTest.php @@ -197,6 +197,39 @@ describe('GET /api/v1/github-apps', function () { ]); }); + test('does not return system-wide github app secrets owned by another team', function () { + $otherTeam = Team::factory()->create(); + $otherPrivateKey = PrivateKey::create([ + 'name' => 'System Key', + 'private_key' => validGithubAppsApiPrivateKey(), + 'team_id' => $otherTeam->id, + ]); + GithubApp::create([ + 'name' => 'Foreign System GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 11111, + 'installation_id' => 22222, + 'client_id' => 'system-client-id', + 'client_secret' => 'foreign-client-secret', + 'webhook_secret' => 'foreign-webhook-secret', + 'private_key_id' => $otherPrivateKey->id, + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + ]); + + $sensitiveToken = createGithubAppsApiToken($this, ['read', 'read:sensitive']); + + $response = $this->withToken($sensitiveToken) + ->getJson('/api/v1/github-apps') + ->assertSuccessful() + ->assertJsonFragment(['name' => 'Foreign System GitHub App']); + + expect($response->json('0')) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('webhook_secret'); + }); + test('does not return other teams github apps', function () { // Create a GitHub app for this team GithubApp::create([ diff --git a/tests/Feature/Api/GitlabAppsApiTest.php b/tests/Feature/Api/GitlabAppsApiTest.php index 65332c0dd6..f18829c205 100644 --- a/tests/Feature/Api/GitlabAppsApiTest.php +++ b/tests/Feature/Api/GitlabAppsApiTest.php @@ -64,6 +64,36 @@ describe('GET /api/v1/gitlab-apps', function () { expect($response->json('0'))->not->toHaveKey('client_secret') ->and($response->json('0'))->not->toHaveKey('webhook_token'); }); + + test('does not return system-wide gitlab app secrets owned by another team', function () { + $otherTeam = Team::factory()->create(); + GitlabApp::create([ + 'name' => 'Foreign System GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'client_id' => 'foreign-client-id', + 'client_secret' => 'foreign-client-secret', + 'webhook_token' => 'foreign-webhook-token', + 'access_token' => 'foreign-access-token', + 'refresh_token' => 'foreign-refresh-token', + 'team_id' => $otherTeam->id, + 'is_system_wide' => true, + ]); + + session(['currentTeam' => $this->team]); + $sensitiveToken = $this->user->createToken('sensitive-token', ['read', 'read:sensitive'])->plainTextToken; + + $response = $this->withToken($sensitiveToken) + ->getJson('/api/v1/gitlab-apps') + ->assertSuccessful() + ->assertJsonFragment(['name' => 'Foreign System GitLab']); + + expect($response->json('0')) + ->not->toHaveKey('client_secret') + ->not->toHaveKey('webhook_token') + ->not->toHaveKey('access_token') + ->not->toHaveKey('refresh_token'); + }); }); describe('POST /api/v1/gitlab-apps', function () { diff --git a/tests/Feature/Api/TeamTokenTeamApiTest.php b/tests/Feature/Api/TeamTokenTeamApiTest.php index 4f45562ea5..447579e294 100644 --- a/tests/Feature/Api/TeamTokenTeamApiTest.php +++ b/tests/Feature/Api/TeamTokenTeamApiTest.php @@ -8,6 +8,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.driver', 'file'); + config()->set('cache.default', 'array'); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true])); $this->team = Team::factory()->create(['name' => 'Token Team']); @@ -27,6 +30,27 @@ function teamTokenApiHeaders(string $bearerToken): array } describe('token team endpoints', function () { + test('legacy team endpoints are restricted to the token team', function () { + $otherTeam = Team::factory()->create(['name' => 'Other Team']); + $otherMember = User::factory()->create(); + $otherTeam->members()->attach($this->user->id, ['role' => 'owner']); + $otherTeam->members()->attach($otherMember->id, ['role' => 'member']); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson('/api/v1/teams') + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $this->team->id); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson("/api/v1/teams/{$otherTeam->id}") + ->assertNotFound(); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->getJson("/api/v1/teams/{$otherTeam->id}/members") + ->assertNotFound(); + }); + test('GET /team returns the token team', function () { $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) ->getJson('/api/v1/team') From 65f4649a7d1e418625efbe7065324bfc0c4bcf3a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:17:08 +0200 Subject: [PATCH 3/8] fix(deployments): ignore newly tracked static configuration (#11430) --- .../ConfigurationDiffer.php | 41 ++----------------- .../ApplicationConfigurationSnapshotTest.php | 19 +++++++++ 2 files changed, 22 insertions(+), 38 deletions(-) diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php index 9833b5be45..ace4888338 100644 --- a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php +++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php @@ -17,28 +17,8 @@ class ConfigurationDiffer */ private const IGNORED_KEYS = ['build.docker_compose']; - /** - * Defaults for fields introduced after configuration snapshots were first - * stored. Older snapshots omitted these keys, which should not make an - * unchanged default look like a pending configuration change. - * - * @var array - */ - private const INTRODUCED_DEFAULTS = [ - 'build.is_static' => false, - 'build.is_spa' => false, - 'build.is_git_submodules_enabled' => true, - 'build.is_git_lfs_enabled' => true, - 'build.is_git_shallow_clone_enabled' => true, - 'build.is_env_sorting_enabled' => [false, true], - 'runtime.is_consistent_container_name_enabled' => false, - 'runtime.is_container_label_escape_enabled' => true, - 'runtime.is_container_label_readonly_enabled' => true, - 'runtime.is_log_drain_enabled' => false, - 'runtime.is_swarm_only_worker_nodes' => true, - 'runtime.is_preserve_repository_enabled' => false, - 'domains.noindex_domains' => [], - ]; + /** @var array */ + private const DYNAMIC_SECTIONS = ['environment', 'storage']; /** * @param array $previousSnapshot @@ -59,11 +39,7 @@ class ConfigurationDiffer $previous = $previousItems[$key] ?? null; $current = $currentItems[$key] ?? null; - if ( - $previous === null - && array_key_exists($key, self::INTRODUCED_DEFAULTS) - && $this->matchesIntroducedDefault($key, data_get($current, 'compare_value')) - ) { + if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) { continue; } @@ -127,17 +103,6 @@ class ConfigurationDiffer return ConfigurationDiff::fromChanges($changes); } - private function matchesIntroducedDefault(string $key, mixed $value): bool - { - $default = self::INTRODUCED_DEFAULTS[$key]; - - if (is_array($default) && $default !== [] && array_is_list($default)) { - return in_array($value, $default, true); - } - - return $value === $default; - } - /** * Reduce two multi-line values to only the lines that differ, so the modal * shows just the changed container labels instead of the whole block. diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index afad0593f3..b7901abb68 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -296,6 +296,25 @@ it('accepts the historical environment sorting default in older snapshots', func expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); }); +it('does not report newly tracked static configuration as a pending change', function () { + $application = snapshotTestApplication(); + $currentSnapshot = $application->deploymentConfigurationSnapshot(); + $previousSnapshot = $currentSnapshot; + + data_set($currentSnapshot, 'sections.runtime.items', [ + ...data_get($currentSnapshot, 'sections.runtime.items'), + [ + 'key' => 'newly_tracked_setting', + 'label' => 'Newly tracked setting', + 'impact' => 'redeploy', + 'compare_value' => 'already configured', + 'display_value' => 'already configured', + ], + ]); + + expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse(); +}); + it('detects environment variable value changes without exposing secret values', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ From 15072b9cf7e5bce235c381f916a5b73f699e4763 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:23:04 +0200 Subject: [PATCH 4/8] fix(ui): release modal scroll lock before confirmation submission Close the confirmation modal before submitting destructive actions and reopen it when submission fails or returns an error. --- .../components/modal-confirmation.blade.php | 41 +++++++++++-------- tests/Feature/ModalScrollLockTest.php | 8 ++++ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 0e4350f50f..d63c1953f2 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -334,12 +334,15 @@ step++; } else { submitting = true; - submitForm().then((result) => { - submitting = false; - modalOpen = false; - resetModal(); - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + resetModal(); + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); } "> @@ -388,17 +391,21 @@ $wire.dispatch(dispatchEventType, dispatchEventMessage); } submitting = true; - submitForm().then((result) => { - submitting = false; - if (result === true) { - modalOpen = false; - resetModal(); - } else { - passwordError = result; - password = ''; - } - }).catch(() => { - submitting = false; + modalOpen = false; + $nextTick(() => { + submitForm().then((result) => { + submitting = false; + if (result === true) { + resetModal(); + } else { + modalOpen = true; + passwordError = result; + password = ''; + } + }).catch(() => { + submitting = false; + modalOpen = true; + }); }); "> diff --git a/tests/Feature/ModalScrollLockTest.php b/tests/Feature/ModalScrollLockTest.php index 7863325a20..17d293519d 100644 --- a/tests/Feature/ModalScrollLockTest.php +++ b/tests/Feature/ModalScrollLockTest.php @@ -7,3 +7,11 @@ test('confirmation modal closes before dispatching an event that can open anothe '/if \(dispatchEvent\) \{\s*modalOpen = false;\s*\$nextTick\(\(\) => \$wire\.dispatch\(dispatchEventType, dispatchEventMessage\)\);/s' ); }); + +test('confirmation modal releases its scroll lock before submitting a destructive action', function () { + $modal = file_get_contents(resource_path('views/components/modal-confirmation.blade.php')); + + expect($modal) + ->toMatch('/submitting = true;\s*modalOpen = false;\s*\$nextTick\(\(\) => \{\s*submitForm\(\)/s') + ->toMatch('/if \(result === true\) \{\s*resetModal\(\);/s'); +}); From 83f1a2e50374c27125671084b445b2599815f114 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:04:14 +0200 Subject: [PATCH 5/8] fix(domains): allow adding domains without explicit ports (#11442) --- app/Support/DomainUrlParts.php | 4 ++-- tests/Unit/DomainUrlPartsTest.php | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/Support/DomainUrlParts.php b/app/Support/DomainUrlParts.php index 2e6da7868c..c86d5fa9a9 100644 --- a/app/Support/DomainUrlParts.php +++ b/app/Support/DomainUrlParts.php @@ -4,11 +4,11 @@ namespace App\Support; class DomainUrlParts { - public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string + public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string { $scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https'; $host = trim($host); - $port = trim($port); + $port = trim((string) $port); $path = trim($path); if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) { diff --git a/tests/Unit/DomainUrlPartsTest.php b/tests/Unit/DomainUrlPartsTest.php index 086599a1a2..5c2994934c 100644 --- a/tests/Unit/DomainUrlPartsTest.php +++ b/tests/Unit/DomainUrlPartsTest.php @@ -28,3 +28,8 @@ it('defaults empty values for an invalid URL', function () { it('preserves an explicitly configured default port', function () { expect(DomainUrlParts::split('https://app.example.com:443')['port'])->toBe('443'); }); + +it('composes a domain when Livewire hydrates an empty numeric port as null', function () { + expect(DomainUrlParts::compose('https', 'app.example.com', null)) + ->toBe('https://app.example.com'); +}); From 7bbd91175f3865018b17aa3273b189c511ce459f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:12:56 +0200 Subject: [PATCH 6/8] Revert "Merge branch 'next' into main" This reverts commit 2598f3e4c8de963391fcd3e23595cad2bb15abb7, reversing changes made to 83f1a2e50374c27125671084b445b2599815f114. --- .env.testing | 1 - app/Actions/Fortify/CreateNewUser.php | 2 +- app/Actions/Server/CheckUpdates.php | 40 +-- app/Actions/Server/InstallDocker.php | 27 +- app/Actions/Server/InstallPrerequisites.php | 16 - app/Actions/Server/UpdatePackage.php | 4 - .../Exceptions/OidcDiscoveryException.php | 5 - app/Auth/Oidc/Exceptions/OidcException.php | 7 - .../Oidc/Exceptions/OidcJwksException.php | 5 - .../OidcSigningKeyNotFoundException.php | 5 - .../Oidc/Exceptions/OidcTokenException.php | 5 - app/Auth/Oidc/OidcConfig.php | 34 -- app/Auth/Oidc/OidcDiscoveryDocument.php | 61 ---- app/Auth/Oidc/OidcDiscoveryService.php | 97 ----- app/Auth/Oidc/OidcTokenValidator.php | 199 ----------- app/Auth/Oidc/OidcUser.php | 32 -- app/Auth/Oidc/Socialite/OidcProvider.php | 299 ---------------- app/Helpers/SshMultiplexingHelper.php | 8 +- app/Http/Controllers/OauthController.php | 61 ++-- app/Livewire/Notifications/Discord.php | 24 -- app/Livewire/Notifications/Email.php | 121 ++----- app/Livewire/Notifications/Pushover.php | 28 -- app/Livewire/Notifications/Slack.php | 26 -- app/Livewire/Notifications/Telegram.php | 28 -- app/Livewire/Notifications/Webhook.php | 24 -- app/Livewire/Profile/Index.php | 59 +--- app/Livewire/Project/Service/Storage.php | 14 +- .../Shared/EnvironmentVariable/Show.php | 22 +- .../EnvironmentVariable/ShowHardcoded.php | 19 - app/Livewire/Project/Shared/Storages/All.php | 19 - .../Security/IntegrationTokenEditor.php | 114 ------ .../Security/IntegrationTokenForm.php | 81 ----- app/Livewire/Security/IntegrationTokens.php | 41 --- app/Livewire/Server/LogDrains.php | 72 ---- app/Livewire/Settings/Advanced.php | 6 - app/Livewire/SettingsEmail.php | 124 ++----- app/Livewire/SettingsOauth.php | 334 ++++++------------ app/Models/EnvironmentVariable.php | 17 - app/Models/InstanceSettings.php | 16 - app/Models/IntegrationToken.php | 38 -- app/Models/OauthIdentity.php | 35 -- app/Models/OauthSetting.php | 51 +-- app/Models/Team.php | 5 - app/Models/User.php | 17 +- app/Policies/IntegrationTokenPolicy.php | 34 -- app/Providers/AppServiceProvider.php | 36 +- app/Providers/AuthServiceProvider.php | 3 - app/Providers/DuskServiceProvider.php | 21 ++ app/Providers/FortifyServiceProvider.php | 8 +- app/Services/Auth/OauthLoginService.php | 228 ------------ app/Services/CloudflareTokenValidator.php | 42 --- bootstrap/helpers/shared.php | 9 +- bootstrap/helpers/socialite.php | 28 +- composer.json | 2 +- composer.lock | 142 +++++++- config/app.php | 4 +- config/services.php | 8 - ...ation_deployment_configuration_columns.php | 6 - ...dd_oidc_fields_to_oauth_settings_table.php | 40 --- ...4_091631_create_oauth_identities_table.php | 36 -- ...tion_policy_to_instance_settings_table.php | 28 -- ...join_root_team_to_oauth_settings_table.php | 28 -- ...000000_create_integration_tokens_table.php | 29 -- database/seeders/OauthSettingSeeder.php | 1 - database/seeders/UserSeeder.php | 2 + lang/de.json | 1 - lang/en.json | 1 - lang/pl.json | 1 - public/svgs/oidc.svg | 5 - resources/js/app.js | 2 - resources/js/copy-button.js | 35 -- resources/views/auth/login.blade.php | 8 +- .../views/components/copy-button.blade.php | 32 +- .../components/forms/copy-button.blade.php | 28 ++ .../components/forms/copy-input.blade.php | 15 - .../components/modal-confirmation.blade.php | 13 +- resources/views/components/reicon.blade.php | 1 - .../security/settings-layout.blade.php | 6 - .../components/settings/sidebar.blade.php | 18 - resources/views/layouts/base.blade.php | 24 ++ .../views/livewire/profile/index.blade.php | 22 +- .../application/internal-access.blade.php | 8 +- .../project/service/storage.blade.php | 16 + .../shared/environment-variable/all.blade.php | 3 +- .../show-hardcoded.blade.php | 5 +- .../environment-variable/show.blade.php | 5 +- .../shared/partials/dns-copy-cell.blade.php | 41 ++- .../project/shared/resource-details.blade.php | 20 +- .../project/shared/storages/all.blade.php | 19 +- .../volume-backups/executions.blade.php | 2 +- .../project/shared/webhooks.blade.php | 6 +- .../livewire/security/api-tokens.blade.php | 7 +- .../integration-token-editor.blade.php | 52 --- .../security/integration-token-form.blade.php | 49 --- .../security/integration-tokens.blade.php | 84 ----- .../server/ca-certificate/show.blade.php | 2 +- .../server/security/patches.blade.php | 4 +- .../views/livewire/settings-oauth.blade.php | 142 +++----- .../livewire/settings/advanced.blade.php | 11 +- .../views/livewire/team/invitations.blade.php | 9 +- routes/web.php | 5 - templates/service-templates-latest.json | 4 +- templates/service-templates.json | 4 +- tests/Browser/LoginTest.php | 27 ++ tests/Browser/Project/ProjectAddNewTest.php | 34 ++ tests/Browser/Project/ProjectSearchTest.php | 29 ++ tests/Browser/Project/ProjectTest.php | 27 ++ tests/Browser/console/.gitignore | 2 + tests/Browser/source/.gitignore | 2 + tests/DuskTestCase.php | 57 +++ .../EnvironmentVariableValueHidingTest.php | 18 +- tests/Feature/CopyButtonComponentTest.php | 38 +- tests/Feature/EnableActionButtonsTest.php | 179 ---------- .../EnvironmentVariableAsyncLoadTest.php | 2 +- .../EnvironmentVariableCopyValueTest.php | 151 -------- .../LogDrain/LogDrainToggleRollbackTest.php | 45 --- tests/Feature/LoginPageBrandingTest.php | 22 -- tests/Feature/OauthControllerTest.php | 114 +----- tests/Feature/OauthRegistrationPolicyTest.php | 52 --- tests/Feature/OidcOauthControllerTest.php | 275 -------------- .../PersistentStorageVolumesLayoutTest.php | 59 +--- tests/Feature/ProfileSsoIndicatorTest.php | 91 ----- .../Feature/ResourceDetailsVisibilityTest.php | 16 +- .../Security/IntegrationTokenFormTest.php | 253 ------------- .../SecuritySettingsNavigationTest.php | 2 - .../SettingsEmailProviderExclusivityTest.php | 64 ---- tests/Feature/SettingsNavigationTest.php | 52 --- tests/Feature/SettingsOauthTest.php | 277 --------------- tests/Feature/SshMultiplexingLockTest.php | 2 +- tests/Feature/TeamInvitationUiTest.php | 14 +- tests/Feature/UserSeederTest.php | 16 - .../Server/AlpinePackageManagerTest.php | 62 ---- .../ApplicationConfigurationSnapshotTest.php | 14 +- tests/Unit/OauthSettingTest.php | 30 -- tests/Unit/OidcDiscoveryServiceTest.php | 119 ------- tests/Unit/OidcProviderPkceTest.php | 148 -------- tests/Unit/OidcTokenValidatorTest.php | 187 ---------- tests/Unit/SshMultiplexingDisableTest.php | 10 - tests/v4/Feature/DangerDeleteResourceTest.php | 18 +- 139 files changed, 859 insertions(+), 5337 deletions(-) delete mode 100644 app/Auth/Oidc/Exceptions/OidcDiscoveryException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcJwksException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php delete mode 100644 app/Auth/Oidc/Exceptions/OidcTokenException.php delete mode 100644 app/Auth/Oidc/OidcConfig.php delete mode 100644 app/Auth/Oidc/OidcDiscoveryDocument.php delete mode 100644 app/Auth/Oidc/OidcDiscoveryService.php delete mode 100644 app/Auth/Oidc/OidcTokenValidator.php delete mode 100644 app/Auth/Oidc/OidcUser.php delete mode 100644 app/Auth/Oidc/Socialite/OidcProvider.php delete mode 100644 app/Livewire/Security/IntegrationTokenEditor.php delete mode 100644 app/Livewire/Security/IntegrationTokenForm.php delete mode 100644 app/Livewire/Security/IntegrationTokens.php delete mode 100644 app/Models/IntegrationToken.php delete mode 100644 app/Models/OauthIdentity.php delete mode 100644 app/Policies/IntegrationTokenPolicy.php create mode 100644 app/Providers/DuskServiceProvider.php delete mode 100644 app/Services/Auth/OauthLoginService.php delete mode 100644 app/Services/CloudflareTokenValidator.php delete mode 100644 database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php delete mode 100644 database/migrations/2026_06_04_091631_create_oauth_identities_table.php delete mode 100644 database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php delete mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php delete mode 100644 database/migrations/2026_08_15_000000_create_integration_tokens_table.php delete mode 100644 public/svgs/oidc.svg delete mode 100644 resources/js/copy-button.js create mode 100644 resources/views/components/forms/copy-button.blade.php delete mode 100644 resources/views/components/forms/copy-input.blade.php delete mode 100644 resources/views/livewire/security/integration-token-editor.blade.php delete mode 100644 resources/views/livewire/security/integration-token-form.blade.php delete mode 100644 resources/views/livewire/security/integration-tokens.blade.php create mode 100644 tests/Browser/LoginTest.php create mode 100644 tests/Browser/Project/ProjectAddNewTest.php create mode 100644 tests/Browser/Project/ProjectSearchTest.php create mode 100644 tests/Browser/Project/ProjectTest.php create mode 100644 tests/Browser/console/.gitignore create mode 100644 tests/Browser/source/.gitignore create mode 100644 tests/DuskTestCase.php delete mode 100644 tests/Feature/EnableActionButtonsTest.php delete mode 100644 tests/Feature/EnvironmentVariableCopyValueTest.php delete mode 100644 tests/Feature/LogDrain/LogDrainToggleRollbackTest.php delete mode 100644 tests/Feature/OauthRegistrationPolicyTest.php delete mode 100644 tests/Feature/OidcOauthControllerTest.php delete mode 100644 tests/Feature/ProfileSsoIndicatorTest.php delete mode 100644 tests/Feature/Security/IntegrationTokenFormTest.php delete mode 100644 tests/Feature/SettingsEmailProviderExclusivityTest.php delete mode 100644 tests/Feature/SettingsNavigationTest.php delete mode 100644 tests/Feature/SettingsOauthTest.php delete mode 100644 tests/Feature/UserSeederTest.php delete mode 100644 tests/Unit/Actions/Server/AlpinePackageManagerTest.php delete mode 100644 tests/Unit/OauthSettingTest.php delete mode 100644 tests/Unit/OidcDiscoveryServiceTest.php delete mode 100644 tests/Unit/OidcProviderPkceTest.php delete mode 100644 tests/Unit/OidcTokenValidatorTest.php diff --git a/.env.testing b/.env.testing index d445b5afed..1a73117986 100644 --- a/.env.testing +++ b/.env.testing @@ -1,7 +1,6 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true -APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index d437a3a176..44a03c17da 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { $settings = instanceSettings(); - if (! $settings->isPasswordRegistrationAllowed()) { + if (! $settings->is_registration_enabled) { abort(403); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index 5cf5658f8f..f90e007089 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -3,7 +3,6 @@ namespace App\Actions\Server; use App\Models\Server; -use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class CheckUpdates @@ -107,15 +106,6 @@ class CheckUpdates $out['osId'] = $osId; $out['package_manager'] = $packageManager; - return $out; - case 'apk': - instant_remote_process(['apk update -q'], $server); - $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); - - $out = $this->parseApkOutput($output); - $out['osId'] = $osId; - $out['package_manager'] = $packageManager; - return $out; default: return [ @@ -276,39 +266,11 @@ class CheckUpdates // Include unparsed lines in the result for debugging if any exist if (! empty($unparsedLines)) { $result['unparsed_lines'] = $unparsedLines; - Log::debug('Pacman output contained unparsed lines', [ + \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ 'unparsed_lines' => $unparsedLines, ]); } return $result; } - - private function parseApkOutput(string $output): array - { - $updates = []; - $lines = explode("\n", $output); - - foreach ($lines as $line) { - // Skip empty lines - if (empty($line)) { - continue; - } - - // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] - if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) { - $updates[] = [ - 'package' => $matches[1], - 'new_version' => $matches[2], - 'architecture' => $matches[3], - 'current_version' => $matches[4], - ]; - } - } - - return [ - 'total_updates' => count($updates), - 'updates' => $updates, - ]; - } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 552445d728..2e08ec6ad9 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,8 +79,6 @@ class InstallDocker $command = $command->merge([$this->getSuseDockerInstallCommand()]); } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); - } elseif ($supported_os_type->contains('alpine')) { - $command = $command->merge([$this->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -95,8 +93,9 @@ class InstallDocker "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null", 'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json', "echo 'Restarting Docker Engine...'", + 'systemctl enable docker >/dev/null 2>&1 || true', + 'systemctl restart docker', ]); - $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine'))); if ($server->isSwarm()) { $command = $command->merge([ 'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true', @@ -155,28 +154,6 @@ class InstallDocker 'systemctl start docker.service'; } - private function getAlpineDockerInstallCommand(): string - { - return 'apk update && '. - 'apk add docker docker-cli-buildx docker-cli-compose && '. - 'mkdir -p /etc/docker'; - } - - private function getDockerServiceCommands(bool $usesOpenRc): array - { - if ($usesOpenRc) { - return [ - 'rc-update add docker default', - 'rc-service docker restart', - ]; - } - - return [ - 'systemctl enable docker >/dev/null 2>&1 || true', - 'systemctl restart docker', - ]; - } - private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 57fd4f1d7c..84be7f2068 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,8 +53,6 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", 'pacman -Syu --noconfirm --needed curl wget git jq', ]); - } elseif ($supported_os_type->contains('alpine')) { - $command = $command->merge($this->getAlpinePrerequisiteCommands()); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } @@ -63,18 +61,4 @@ class InstallPrerequisites return remote_process($command, $server); } - - private function getAlpinePrerequisiteCommands(): array - { - return [ - "echo 'Installing Prerequisites for Alpine Linux...'", - "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", - 'apk update', - 'command -v bash >/dev/null || apk add bash', - 'command -v curl >/dev/null || apk add curl', - 'command -v wget >/dev/null || apk add wget', - 'command -v git >/dev/null || apk add git', - 'command -v jq >/dev/null || apk add jq', - ]; - } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index 2b06e06011..ab0ca94943 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -58,10 +58,6 @@ class UpdatePackage $commandAll = 'pacman -Syu --noconfirm'; $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; - case 'apk': - $commandAll = 'apk update && apk upgrade'; - $commandInstall = 'apk upgrade '.$sanitizedPackage; - break; default: return [ 'error' => 'OS not supported', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php deleted file mode 100644 index e4a2ba0dfe..0000000000 --- a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php +++ /dev/null @@ -1,5 +0,0 @@ - $scopes - */ - public function __construct( - public string $issuerUrl, - public string $clientId, - public string $clientSecret, - public string $redirectUri, - public array $scopes = ['openid', 'email', 'profile'], - public bool $usePkce = true, - public int $clockSkewSeconds = 60, - ) {} - - public static function fromOauthSetting(OauthSetting $setting): self - { - return new self( - issuerUrl: rtrim((string) $setting->base_url, '/'), - clientId: (string) $setting->client_id, - clientSecret: (string) $setting->client_secret, - redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'), - scopes: $setting->scopeList(), - usePkce: $setting->use_pkce ?? true, - clockSkewSeconds: $setting->clock_skew_seconds ?? 60, - ); - } -} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php deleted file mode 100644 index d17061c51d..0000000000 --- a/app/Auth/Oidc/OidcDiscoveryDocument.php +++ /dev/null @@ -1,61 +0,0 @@ - $supportedScopes - * @param array $supportedClaims - * @param array $idTokenSigningAlgValuesSupported - */ - public function __construct( - public string $issuer, - public string $authorizationEndpoint, - public string $tokenEndpoint, - public string $userinfoEndpoint, - public string $jwksUri, - public ?string $endSessionEndpoint = null, - public array $supportedScopes = [], - public array $supportedClaims = [], - public array $idTokenSigningAlgValuesSupported = [], - ) {} - - /** - * @param array $payload - */ - public static function fromArray(array $payload): self - { - foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) { - if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') { - throw new OidcDiscoveryException("Discovery document is missing required field: {$field}"); - } - } - - return new self( - issuer: $payload['issuer'], - authorizationEndpoint: $payload['authorization_endpoint'], - tokenEndpoint: $payload['token_endpoint'], - userinfoEndpoint: $payload['userinfo_endpoint'], - jwksUri: $payload['jwks_uri'], - endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null, - supportedScopes: self::stringList($payload['scopes_supported'] ?? []), - supportedClaims: self::stringList($payload['claims_supported'] ?? []), - idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []), - ); - } - - /** - * @return array - */ - private static function stringList(mixed $value): array - { - if (! is_array($value)) { - return []; - } - - return array_values(array_map('strval', $value)); - } -} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php deleted file mode 100644 index 0847afc9a7..0000000000 --- a/app/Auth/Oidc/OidcDiscoveryService.php +++ /dev/null @@ -1,97 +0,0 @@ -assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.')); - - $issuerUrl = rtrim($issuerUrl, '/'); - $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); - - return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument { - $url = $issuerUrl.'/.well-known/openid-configuration'; - - try { - $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url); - } catch (Throwable $e) { - throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e); - } - - if ($response->failed()) { - throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}"); - } - - $json = $response->json(); - if (! is_array($json) || $json === []) { - throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.'); - } - - $discovery = OidcDiscoveryDocument::fromArray($json); - if (rtrim($discovery->issuer, '/') !== $issuerUrl) { - throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); - } - - return $discovery; - }); - } - - /** - * Fetch the JWKS for the given URI. - * - * When $forceRefresh is true the cached document is bypassed so freshly - * rotated signing keys become visible immediately. A short cooldown still - * prevents a flood of upstream requests if many logins miss the same kid. - * - * @return array - */ - public function jwks(string $jwksUri, bool $forceRefresh = false): array - { - $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.')); - - $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); - - if ($forceRefresh) { - $cooldownKey = $cacheKey.':refresh'; - if (Cache::add($cooldownKey, true, 60)) { - Cache::forget($cacheKey); - } - } - - return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { - try { - $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri); - } catch (Throwable $e) { - throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e); - } - - if ($response->failed()) { - throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}"); - } - - $json = $response->json(); - if (! is_array($json) || ! is_array($json['keys'] ?? null)) { - throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'."); - } - - return $json; - }); - } - - private function assertHttpsUrl(string $url, Throwable $exception): void - { - $parts = parse_url($url); - - if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') { - throw $exception; - } - } -} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php deleted file mode 100644 index a8563611dd..0000000000 --- a/app/Auth/Oidc/OidcTokenValidator.php +++ /dev/null @@ -1,199 +0,0 @@ - $jwks - * @return array - */ - public function validate( - string $idToken, - OidcDiscoveryDocument $discovery, - array $jwks, - string $clientId, - ?string $expectedNonce = null, - int $clockSkewSeconds = 60, - ): array { - $kid = $this->extractKid($idToken); - - try { - $keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM); - } catch (Throwable $e) { - throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e); - } - - // Surface an unknown signing key distinctly so the caller can refresh - // the JWKS once (key rotation) before giving up. - if (! array_key_exists($kid, $keys)) { - throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); - } - - $previousLeeway = JWT::$leeway; - JWT::$leeway = $clockSkewSeconds; - - try { - // Validates signature, header alg against the key alg (RS256), - // exp, nbf and iat. Throws on any failure. - $claims = (array) JWT::decode($idToken, $keys); - } catch (OidcTokenException $e) { - throw $e; - } catch (Throwable $e) { - throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e); - } finally { - JWT::$leeway = $previousLeeway; - } - - $this->assertExpiry($claims); - $this->assertIssuer($claims, $discovery->issuer); - $this->assertAudience($claims, $clientId); - $this->assertNonce($claims, $expectedNonce); - $this->assertSubject($claims); - - return $claims; - } - - /** - * Drop JWKS entries explicitly marked for anything other than signing - * (e.g. "use":"enc") so they can never verify an id_token signature. - * firebase/php-jwt does not honour the "use" parameter on its own. - * - * @param array $jwks - * @return array - */ - private function signingKeysOnly(array $jwks): array - { - $keys = array_values(array_filter( - $jwks['keys'] ?? [], - fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'), - )); - - return ['keys' => $keys]; - } - - /** - * Decode just the JWT header to read the kid before signature - * verification, so an unknown key can be reported as a rotation miss. - */ - private function extractKid(string $idToken): string - { - $segments = explode('.', $idToken); - if (count($segments) !== 3) { - throw new OidcTokenException('Malformed id_token.'); - } - - $header = json_decode($this->base64UrlDecode($segments[0]), true); - if (! is_array($header)) { - throw new OidcTokenException('id_token header contains invalid JSON.'); - } - - if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) { - throw new OidcTokenException('id_token uses a disallowed algorithm.'); - } - - $kid = $header['kid'] ?? null; - if (! is_string($kid) || $kid === '') { - throw new OidcTokenException('id_token header is missing kid.'); - } - - return $kid; - } - - private function base64UrlDecode(string $value): string - { - $remainder = strlen($value) % 4; - if ($remainder !== 0) { - $value .= str_repeat('=', 4 - $remainder); - } - - $decoded = base64_decode(strtr($value, '-_', '+/'), true); - if ($decoded === false) { - throw new OidcTokenException('Invalid base64url value in id_token header.'); - } - - return $decoded; - } - - /** - * @param array $claims - */ - private function assertExpiry(array $claims): void - { - // Firebase enforces the exp window when present; OIDC requires it to exist. - if (! is_numeric($claims['exp'] ?? null)) { - throw new OidcTokenException('id_token is missing the exp claim.'); - } - } - - /** - * @param array $claims - */ - private function assertSubject(array $claims): void - { - $subject = $claims['sub'] ?? null; - if (! is_string($subject) || $subject === '') { - throw new OidcTokenException('id_token subject is missing or invalid.'); - } - } - - /** - * @param array $claims - */ - private function assertIssuer(array $claims, string $expectedIssuer): void - { - if (($claims['iss'] ?? null) !== $expectedIssuer) { - throw new OidcTokenException('id_token issuer does not match discovery issuer.'); - } - } - - /** - * @param array $claims - */ - private function assertAudience(array $claims, string $clientId): void - { - $audience = $claims['aud'] ?? null; - if (is_string($audience)) { - $audience = [$audience]; - } - - if (! is_array($audience) || ! in_array($clientId, $audience, true)) { - throw new OidcTokenException('id_token audience does not include configured client id.'); - } - - if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) { - throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.'); - } - - if (isset($claims['azp']) && $claims['azp'] !== $clientId) { - throw new OidcTokenException('id_token azp does not match configured client id.'); - } - } - - /** - * @param array $claims - */ - private function assertNonce(array $claims, ?string $expectedNonce): void - { - if ($expectedNonce === null) { - return; - } - - if (($claims['nonce'] ?? null) !== $expectedNonce) { - throw new OidcTokenException('id_token nonce does not match.'); - } - } -} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php deleted file mode 100644 index 645130e019..0000000000 --- a/app/Auth/Oidc/OidcUser.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ - public array $idTokenClaims = []; - - /** - * @param array $claims - */ - public function setIdTokenClaims(array $claims): self - { - $this->idTokenClaims = $claims; - $this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null; - $this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null; - $this->emailVerified = ($claims['email_verified'] ?? false) === true; - - return $this; - } -} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php deleted file mode 100644 index 383b0cc910..0000000000 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ /dev/null @@ -1,299 +0,0 @@ - - */ - protected $scopes = ['openid', 'email', 'profile']; - - protected $scopeSeparator = ' '; - - protected ?OidcConfig $oidcConfig = null; - - protected ?OidcDiscoveryDocument $discovery = null; - - public function __construct( - Request $request, - protected OidcDiscoveryService $discoveryService, - protected OidcTokenValidator $tokenValidator, - string $clientId, - string $clientSecret, - string $redirectUrl, - ) { - parent::__construct($request, $clientId, $clientSecret, $redirectUrl); - } - - public function setConfig(OidcConfig $config): self - { - $this->oidcConfig = $config; - $this->clientId = $config->clientId; - $this->clientSecret = $config->clientSecret; - $this->redirectUrl = $config->redirectUri; - $this->scopes = $config->scopes; - $this->discovery = null; - - return $this; - } - - public function getConfig(): OidcConfig - { - if ($this->oidcConfig === null) { - throw new OidcException('OIDC provider config is not set.'); - } - - return $this->oidcConfig; - } - - protected function getAuthUrl($state): string - { - $config = $this->getConfig(); - $nonce = Str::random(40); - $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce); - - $extra = ['nonce' => $nonce]; - if ($config->usePkce) { - $verifier = $this->generateCodeVerifier(); - $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier); - $extra['code_challenge'] = $this->codeChallenge($verifier); - $extra['code_challenge_method'] = 'S256'; - } - - return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state) - .'&'.http_build_query($extra, '', '&', $this->encodingType); - } - - protected function getTokenUrl(): string - { - return $this->resolveDiscovery()->tokenEndpoint; - } - - /** - * @return array - */ - protected function getUserByToken($token): array - { - $response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [ - RequestOptions::HEADERS => [ - 'Accept' => 'application/json', - 'Authorization' => 'Bearer '.$token, - ], - RequestOptions::CONNECT_TIMEOUT => 5, - RequestOptions::TIMEOUT => 10, - ]); - - $decoded = json_decode((string) $response->getBody(), true); - - return is_array($decoded) ? $decoded : []; - } - - /** - * @param array $user - */ - protected function mapUserToObject(array $user) - { - return (new OidcUser)->setRaw($user)->map([ - 'id' => $user['sub'] ?? null, - 'nickname' => $user['preferred_username'] ?? null, - 'name' => $this->resolveName($user), - 'email' => $user['email'] ?? null, - 'avatar' => $user['picture'] ?? null, - ]); - } - - public function user() - { - if ($this->user) { - return $this->user; - } - - if ($this->hasInvalidState()) { - throw new InvalidStateException; - } - - $tokenResponse = $this->getAccessTokenResponse($this->getCode()); - $accessToken = Arr::get($tokenResponse, 'access_token'); - $idToken = Arr::get($tokenResponse, 'id_token'); - - if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') { - throw new OidcException('OIDC token endpoint did not return required tokens.'); - } - - $discovery = $this->resolveDiscovery(); - $config = $this->getConfig(); - $expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state'))); - if ($expectedNonce === null) { - throw new OidcException('OIDC login session expired. Please try again.'); - } - - $claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce); - - $userinfo = $this->getUserByToken($accessToken); - - // OIDC core §5.3.2: the userinfo sub MUST match the id_token sub. - // Reject the response rather than trust unsigned userinfo claims. - $userinfoSub = $userinfo['sub'] ?? null; - if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) { - throw new OidcException('OIDC userinfo subject does not match the id_token subject.'); - } - - $merged = array_merge($userinfo, $claims); - - /** @var OidcUser $user */ - $user = $this->mapUserToObject($merged); - $user->setIdTokenClaims($claims) - ->setToken($accessToken) - ->setRefreshToken(Arr::get($tokenResponse, 'refresh_token')) - ->setExpiresIn(Arr::get($tokenResponse, 'expires_in')); - - return $this->user = $user; - } - - /** - * Validate the id_token, retrying once against a freshly fetched JWKS when - * the signing key is unknown. This keeps logins working immediately after - * the IdP rotates keys instead of failing until the JWKS cache expires. - * - * @return array - */ - protected function validateIdToken( - string $idToken, - OidcDiscoveryDocument $discovery, - OidcConfig $config, - ?string $expectedNonce, - ): array { - foreach ([false, true] as $forceRefresh) { - try { - return $this->tokenValidator->validate( - idToken: $idToken, - discovery: $discovery, - jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh), - clientId: $config->clientId, - expectedNonce: $expectedNonce, - clockSkewSeconds: $config->clockSkewSeconds, - ); - } catch (OidcSigningKeyNotFoundException $e) { - if ($forceRefresh) { - throw $e; - } - } - } - - throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); - } - - /** - * @return array - */ - public function getAccessTokenResponse($code) - { - $fields = $this->getTokenFields($code); - if ($this->getConfig()->usePkce) { - $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); - if ($verifier === null) { - throw new OidcException('OIDC login session expired. Please try again.'); - } - - $fields['code_verifier'] = $verifier; - } - - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => ['Accept' => 'application/json'], - RequestOptions::FORM_PARAMS => $fields, - RequestOptions::CONNECT_TIMEOUT => 5, - RequestOptions::TIMEOUT => 10, - ]); - - $decoded = json_decode((string) $response->getBody(), true); - - return is_array($decoded) ? $decoded : []; - } - - protected function resolveDiscovery(): OidcDiscoveryDocument - { - return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl); - } - - protected function generateCodeVerifier(): string - { - return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '='); - } - - protected function codeChallenge(string $verifier): string - { - return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); - } - - /** - * @param array $user - */ - protected function resolveName(array $user): ?string - { - if (is_string($user['name'] ?? null) && $user['name'] !== '') { - return $user['name']; - } - - $name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? ''))); - - return $name === '' ? null : $name; - } - - protected function putOidcFlowValue(string $key, string $value): void - { - $this->request->session()->put($key, [ - 'value' => $value, - 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp, - ]); - } - - protected function pullOidcFlowValue(string $key): ?string - { - $entry = $this->request->session()->pull($key); - - if (! is_array($entry)) { - return null; - } - - $value = $entry['value'] ?? null; - $expiresAt = $entry['expires_at'] ?? null; - - if (! is_string($value) || $value === '' || ! is_int($expiresAt)) { - return null; - } - - if ($expiresAt < now()->timestamp) { - return null; - } - - return $value; - } - - protected function nonceSessionKey(string $state): string - { - return "oidc.nonce.{$state}"; - } - - protected function verifierSessionKey(string $state): string - { - return "oidc.code_verifier.{$state}"; - } -} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index e7d6d071b4..cbb18945e2 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -243,18 +243,12 @@ class SshMultiplexingHelper $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); - $remoteShellCommand = self::remoteShellCommand(); - return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; } - private static function remoteShellCommand(): string - { - return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi'; - } - public static function getConnectionTimeout(Server $server): int { $timeout = data_get($server, 'settings.connection_timeout'); diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 93d27615a7..4038fe63e2 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -2,60 +2,47 @@ namespace App\Http\Controllers; -use App\Models\OauthSetting; -use App\Services\Auth\OauthLoginService; -use Illuminate\Support\Facades\Log; +use App\Models\User; +use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthController extends Controller { public function redirect(string $provider) { - $oauthSetting = $this->enabledProvider($provider); - $socialiteProvider = get_socialite_provider($oauthSetting->provider); + $socialite_provider = get_socialite_provider($provider); - return $socialiteProvider->redirect(); + return $socialite_provider->redirect(); } - public function callback(string $provider, OauthLoginService $oauthLoginService) + public function callback(string $provider) { try { - $oauthSetting = $this->enabledProvider($provider); - $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); - $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); + $oauthUser = get_socialite_provider($provider)->user(); + $email = trim((string) $oauthUser->email); + if ($email === '') { + abort(403, 'OAuth provider did not return an email address'); + } + $email = strtolower($email); + $user = User::whereEmail($email)->first(); + if (! $user) { + $settings = instanceSettings(); + if (! $settings->is_registration_enabled) { + abort(403, 'Registration is disabled'); + } + + $user = User::create([ + 'name' => $oauthUser->name, + 'email' => $email, + ]); + } + Auth::login($user); return redirect('/'); } catch (\Exception $e) { - $this->logCallbackFailure($provider, $e); - $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; return redirect()->route('login')->withErrors([__($errorCode)]); } } - - private function logCallbackFailure(string $provider, \Throwable $exception): void - { - Log::error('OAuth callback failed.', [ - 'provider' => $provider, - 'exception_class' => $exception::class, - 'exception_message' => $exception->getMessage(), - 'request_error' => request()->query('error'), - 'request_error_description' => request()->query('error_description'), - 'has_code' => request()->query->has('code'), - 'has_state' => request()->query->has('state'), - 'ip' => request()->ip(), - 'exception' => $exception, - ]); - } - - private function enabledProvider(string $provider): OauthSetting - { - $oauthSetting = OauthSetting::where('provider', $provider)->first(); - if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) { - throw new HttpException(403, 'OAuth provider is not enabled'); - } - - return $oauthSetting; - } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 59ecb06e8e..797db83629 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -166,30 +166,6 @@ class Discord extends Component } } - public function toggleDiscordEnabled(): void - { - try { - $this->resetErrorBag(); - - if ($this->discordEnabled) { - $this->discordEnabled = false; - } else { - $this->validate([ - 'discordWebhookUrl' => 'required', - ], [ - 'discordWebhookUrl.required' => 'Discord Webhook URL is required.', - ]); - $this->discordEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - handleError($e, $this); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 2a373a5065..3d95668b91 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,6 +2,7 @@ namespace App\Livewire\Notifications; +use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; @@ -14,7 +15,7 @@ use Livewire\Component; class Email extends Component { - use AuthorizesRequests; + use AuthorizesRequests, TogglesNotificationEvents; protected $listeners = ['refresh' => '$refresh']; @@ -251,59 +252,32 @@ class Email extends Component } } - public function toggleSmtp() - { - try { - $this->resetErrorBag(); - - if ($this->smtpEnabled) { - $this->smtpEnabled = false; - $this->saveModel(); - } else { - $this->validateSmtpSettings(); - $this->smtpEnabled = true; - $this->resendEnabled = false; - $this->submitSmtp(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - - public function toggleResend() - { - try { - $this->resetErrorBag(); - - if ($this->resendEnabled) { - $this->resendEnabled = false; - $this->saveModel(); - } else { - $this->validateResendSettings(); - $this->resendEnabled = true; - $this->smtpEnabled = false; - $this->submitResend(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function submitSmtp() { $this->authorize('update', $this->settings); try { $this->resetErrorBag(); - $this->validateSmtpSettings(); + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); if ($this->smtpEnabled) { $this->settings->resend_enabled = $this->resendEnabled = false; @@ -335,7 +309,17 @@ class Email extends Component try { $this->resetErrorBag(); - $this->validateResendSettings(); + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); if ($this->resendEnabled) { $this->settings->smtp_enabled = $this->smtpEnabled = false; } @@ -352,45 +336,6 @@ class Email extends Component } } - private function validateSmtpSettings(): void - { - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); - } - - private function validateResendSettings(): void - { - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); - } - public function sendTestEmail() { try { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index b1608c5ea2..3b7c3c6aeb 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -159,34 +159,6 @@ class Pushover extends Component } } - public function togglePushoverEnabled() - { - try { - $this->resetErrorBag(); - - if ($this->pushoverEnabled) { - $this->pushoverEnabled = false; - } else { - $this->validate([ - 'pushoverUserKey' => 'required', - 'pushoverApiToken' => 'required', - ], [ - 'pushoverUserKey.required' => 'Pushover User Key is required.', - 'pushoverApiToken.required' => 'Pushover API Token is required.', - ]); - $this->pushoverEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index c4ca7da802..9ee3624025 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -150,32 +150,6 @@ class Slack extends Component } } - public function toggleSlackEnabled() - { - try { - $this->resetErrorBag(); - - if ($this->slackEnabled) { - $this->slackEnabled = false; - } else { - $this->validate([ - 'slackWebhookUrl' => 'required', - ], [ - 'slackWebhookUrl.required' => 'Slack Webhook URL is required.', - ]); - $this->slackEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index 9f19b22f5f..b04d2c73d2 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -252,34 +252,6 @@ class Telegram extends Component } } - public function toggleTelegramEnabled(): void - { - try { - $this->resetErrorBag(); - - if ($this->telegramEnabled) { - $this->telegramEnabled = false; - } else { - $this->validate([ - 'telegramToken' => 'required', - 'telegramChatId' => 'required', - ], [ - 'telegramToken.required' => 'Telegram Token is required.', - 'telegramChatId.required' => 'Telegram Chat ID is required.', - ]); - $this->telegramEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - handleError($e, $this); - } finally { - $this->dispatch('refresh'); - } - } - public function saveModel() { $this->syncData(true); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index ee07694767..fcf1107781 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -144,30 +144,6 @@ class Webhook extends Component } } - public function toggleWebhookEnabled() - { - try { - $this->resetErrorBag(); - - if ($this->webhookEnabled) { - $this->webhookEnabled = false; - } else { - $this->validate([ - 'webhookUrl' => 'required', - ], [ - 'webhookUrl.required' => 'Webhook URL is required.', - ]); - $this->webhookEnabled = true; - } - - $this->saveModel(); - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } - } - public function instantSave() { try { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index ae5d9b3ecd..a20a1231b4 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,15 +2,19 @@ namespace App\Livewire\Profile; +use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; +use Livewire\WithFileUploads; class Index extends Component { + use WithFileUploads; + public int $userId; public string $email; @@ -32,10 +36,6 @@ class Index extends Component public bool $show_verification = false; - public bool $uses_sso = false; - - public ?string $sso_provider_label = null; - public $avatar; public function uploadAvatar(AvatarStorageService $avatarStorage): bool @@ -75,12 +75,8 @@ class Index extends Component $this->name = Auth::user()->name; $this->email = Auth::user()->email; - $oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first(); - $this->uses_sso = $oauthIdentity !== null; - $this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null; - // Check if there's a pending email change - if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) { + if (Auth::user()->hasEmailChangeRequest()) { $this->new_email = Auth::user()->pending_email; $this->show_verification = true; } @@ -105,10 +101,6 @@ class Index extends Component public function requestEmailChange() { try { - if ($this->rejectSsoEmailChange()) { - return; - } - // For self-hosted, check if email is enabled if (! isCloud()) { $settings = instanceSettings(); @@ -167,10 +159,6 @@ class Index extends Component public function verifyEmailChange() { try { - if ($this->rejectSsoEmailChange()) { - return; - } - $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -216,6 +204,7 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); + $this->dispatch('close-email-change-modal'); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -227,10 +216,6 @@ class Index extends Component public function resendVerificationCode() { try { - if ($this->rejectSsoEmailChange()) { - return; - } - // Check if there's a pending request if (! Auth::user()->hasEmailChangeRequest()) { $this->dispatch('error', 'No pending email change request.'); @@ -284,30 +269,6 @@ class Index extends Component $this->dispatch('success', 'Email change request cancelled.'); } - public function showEmailChangeForm() - { - if ($this->rejectSsoEmailChange()) { - return; - } - - $this->show_email_change = true; - $this->new_email = ''; - } - - private function rejectSsoEmailChange(): bool - { - if (! Auth::user()->hasSsoIdentity()) { - return false; - } - - $this->uses_sso = true; - $this->show_email_change = false; - $this->show_verification = false; - $this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.'); - - return true; - } - public function resetPassword() { try { @@ -338,14 +299,6 @@ class Index extends Component } } - private function providerLabel(string $provider): string - { - return match ($provider) { - 'oidc' => 'OIDC', - default => str($provider)->headline()->toString(), - }; - } - public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 6880b5ab09..ce278522b6 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,7 +77,6 @@ class Storage extends Component $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); - $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -202,7 +201,9 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => $this->isSwarm + ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] + : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -339,7 +340,7 @@ class Storage extends Component public function clearForm() { - $this->name = $this->generateDefaultVolumeName(); + $this->name = ''; $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -372,13 +373,6 @@ class Storage extends Component throw new \Exception('No valid resource type for file mount storage type!'); } - private function generateDefaultVolumeName(): string - { - $name = str($this->resource->name)->slug()->value(); - - return ($name ?: 'volume').'-data'; - } - public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index db80cff801..7f37b1fc4d 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,22 +161,6 @@ class Show extends Component $this->valuesLoaded = true; } - public function copyValue(): ?string - { - if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { - return null; - } - - if (! $this->env instanceof ModelsEnvironmentVariable) { - return $this->env->value; - } - - return $this->env->get_real_environment_variables_with_server( - $this->env->resolveReferencedValue(), - $this->env->resourceable, - ); - } - public function syncData(bool $toModel = false) { if ($toModel) { @@ -220,7 +204,7 @@ class Show extends Component $this->is_required = (bool) ($this->env->is_required ?? false); // Use the stored column, not the value-based accessor (that decrypts). $this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false); - $this->isValueHidden = auth()->user()?->isMember() ?? true; + $this->isValueHidden = auth()->user()?->isMember() ?? false; if ($this->valuesLoaded) { $this->hydrateValueFields(); @@ -247,12 +231,12 @@ class Show extends Component $this->is_really_required = $this->is_required && blank($this->value); } - if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + if ($this->env->is_shown_once || auth()->user()?->isMember()) { $this->value = null; $this->real_value = null; } - $this->isValueHidden = auth()->user()?->isMember() ?? true; + $this->isValueHidden = auth()->user()?->isMember() ?? false; } public function checkEnvs() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index c2f0059399..da55dee197 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,7 +2,6 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; -use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -21,10 +20,6 @@ class ShowHardcoded extends Component public bool $isPreview = false; - public ?string $resourceableType = null; - - public ?int $resourceableId = null; - public function mount() { $this->key = $this->env['key']; @@ -33,20 +28,6 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } - public function copyValue(): ?string - { - if (auth()->user()?->isMember() ?? true) { - return null; - } - - return EnvironmentVariable::make([ - 'value' => $this->value, - 'is_preview' => $this->isPreview, - 'resourceable_type' => $this->resourceableType, - 'resourceable_id' => $this->resourceableId, - ])->resolveReferencedValue(); - } - public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index efe54a6a7d..583c2788a4 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,25 +107,6 @@ class All extends Component $this->submit($storageId); } - public function clearHostPath(int $storageId): void - { - $this->authorize('update', $this->resource); - - $storage = $this->findStorageOrFail($storageId); - if ($storage->shouldBeReadOnlyInUI()) { - $this->dispatch('error', 'This volume is read-only.'); - - return; - } - - $storage->host_path = null; - $storage->save(); - $this->forms[$storageId]['hostPath'] = null; - - $this->dispatch('configurationChanged'); - $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); - } - /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php deleted file mode 100644 index 453a7e8ae8..0000000000 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ /dev/null @@ -1,114 +0,0 @@ -integrationToken = IntegrationToken::ownedByCurrentTeam() - ->whereUuid($integration_token_uuid) - ->firstOrFail(); - - $this->authorize('view', $this->integrationToken); - - $this->name = $this->integrationToken->name; - $this->capabilities = $this->integrationToken->capabilities; - } - - protected function rules(): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'newToken' => ['nullable', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], - ]; - } - - protected function messages(): array - { - return [ - 'capabilities.required' => 'Select at least one capability.', - 'capabilities.min' => 'Select at least one capability.', - ]; - } - - public function save(CloudflareTokenValidator $validator): void - { - $this->authorize('update', $this->integrationToken); - $validated = $this->validate(); - $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; - $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() - !== collect($this->integrationToken->capabilities)->sort()->values()->all(); - - try { - if ((filled($validated['newToken']) || $capabilitiesChanged) - && ! $validator->validate($token, $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); - - return; - } - - $updates = [ - 'name' => $validated['name'], - 'capabilities' => $validated['capabilities'], - ]; - - if (filled($validated['newToken'])) { - $updates['token'] = $validated['newToken']; - } - - $this->integrationToken->update($updates); - $this->newToken = ''; - - auditLog('ui.integration_token.updated', [ - 'team_id' => currentTeam()->id, - 'integration_token_uuid' => $this->integrationToken->uuid, - 'integration_token_name' => $this->integrationToken->name, - 'provider' => $this->integrationToken->provider, - 'rotated' => array_key_exists('token', $updates), - ]); - - $this->dispatch( - 'integration-token-updated', - uuid: $this->integrationToken->uuid, - name: $this->integrationToken->name, - capabilities: $this->integrationToken->capabilities, - ); - $this->dispatch('success', 'Integration token updated successfully.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - public function delete(string $password = ''): void - { - $this->authorize('delete', $this->integrationToken); - $this->integrationToken->delete(); - - $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); - $this->dispatch('close-modal'); - $this->dispatch('success', 'Integration token deleted successfully.'); - } - - public function render() - { - return view('livewire.security.integration-token-editor'); - } -} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php deleted file mode 100644 index 7a7637bf5e..0000000000 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ /dev/null @@ -1,81 +0,0 @@ -authorize('create', IntegrationToken::class); - } - - protected function rules(): array - { - return [ - 'provider' => ['required', 'in:cloudflare'], - 'name' => ['required', 'string', 'max:255'], - 'token' => ['required', 'string'], - 'capabilities' => ['required', 'array', 'min:1'], - 'capabilities.*' => ['required', 'in:dns'], - ]; - } - - protected function messages(): array - { - return [ - 'capabilities.required' => 'Select at least one capability.', - 'capabilities.min' => 'Select at least one capability.', - ]; - } - - public function addToken(CloudflareTokenValidator $validator): void - { - $validated = $this->validate(); - - try { - if (! $validator->validate($validated['token'], $validated['capabilities'])) { - $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); - - return; - } - - IntegrationToken::query()->create([ - ...$validated, - 'team_id' => currentTeam()->id, - ]); - - $this->reset(['name', 'token']); - $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); - - if ($this->modal_mode) { - $this->dispatch('close-modal'); - } - - $this->dispatch('success', 'Integration token added successfully.'); - } catch (\Throwable $e) { - handleError($e, $this); - } - } - - public function render() - { - return view('livewire.security.integration-token-form'); - } -} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php deleted file mode 100644 index 39db135b38..0000000000 --- a/app/Livewire/Security/IntegrationTokens.php +++ /dev/null @@ -1,41 +0,0 @@ -authorize('viewAny', IntegrationToken::class); - $this->loadTokens(); - } - - #[On('integrationTokenAdded')] - public function loadTokens(): void - { - $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); - } - - public function deleteToken(int $tokenId, string $password = ''): void - { - $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); - $this->authorize('delete', $token); - $token->delete(); - $this->loadTokens(); - $this->dispatch('success', 'Integration token deleted successfully.'); - } - - public function render() - { - return view('livewire.security.integration-tokens'); - } -} diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index ae53488bd5..3af0a22610 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,49 +177,6 @@ class LogDrains extends Component } } - public function toggleLogDrain(string $type): void - { - $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; - $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; - $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; - - try { - $this->authorize('update', $this->server); - $this->resetErrorBag(); - - $enabledProperty = $this->enabledProperty($type); - - if ($this->{$enabledProperty}) { - $this->{$enabledProperty} = false; - } else { - $this->validateLogDrainSettings($type); - $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; - $this->isLogDrainAxiomEnabled = $type === 'axiom'; - $this->isLogDrainCustomEnabled = $type === 'custom'; - } - - $this->syncData(true); - - if ($this->server->isLogDrainEnabled()) { - StartLogDrain::run($this->server); - $this->dispatch('success', 'Log drain service started.'); - } else { - StopLogDrain::run($this->server); - $this->dispatch('success', 'Log drain service stopped.'); - } - } catch (\Throwable $e) { - // Restore the previously persisted enabled flags so the UI/DB never - // claim a runtime state that the Start/StopLogDrain action failed to apply. - $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; - $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; - $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; - $this->server->settings->save(); - $this->syncData(); - - handleError($e, $this); - } - } - public function submit() { try { @@ -235,33 +192,4 @@ class LogDrains extends Component { return view('livewire.server.log-drains'); } - - private function enabledProperty(string $type): string - { - return match ($type) { - 'newrelic' => 'isLogDrainNewRelicEnabled', - 'axiom' => 'isLogDrainAxiomEnabled', - 'custom' => 'isLogDrainCustomEnabled', - default => throw new \InvalidArgumentException('Unknown log drain type.'), - }; - } - - private function validateLogDrainSettings(string $type): void - { - match ($type) { - 'newrelic' => $this->validate([ - 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], - 'logDrainNewRelicBaseUri' => ['required', 'url'], - ]), - 'axiom' => $this->validate([ - 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], - 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], - ]), - 'custom' => $this->validate([ - 'logDrainCustomConfig' => ['required'], - 'logDrainCustomConfigParser' => ['string', 'nullable'], - ]), - default => throw new \InvalidArgumentException('Unknown log drain type.'), - }; - } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index 38a2f85a73..fd5ee616d9 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -19,9 +19,6 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_registration_enabled; - #[Validate('boolean')] - public bool $disable_registration_when_oauth_enabled; - #[Validate('boolean')] public bool $do_not_track; @@ -62,7 +59,6 @@ class Advanced extends Component { return [ 'is_registration_enabled' => 'boolean', - 'disable_registration_when_oauth_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], @@ -88,7 +84,6 @@ class Advanced extends Component $this->allowed_ips = $this->settings->allowed_ips; $this->do_not_track = $this->settings->do_not_track; $this->is_registration_enabled = $this->settings->is_registration_enabled; - $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled; $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; @@ -204,7 +199,6 @@ class Advanced extends Component try { $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; - $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->custom_dns_servers = $this->custom_dns_servers; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 1426f61f02..9bca0db2e3 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -160,59 +160,30 @@ class SettingsEmail extends Component $this->instantSave('Resend'); } - public function toggleSmtp() - { - try { - $this->resetErrorBag(); - - if ($this->smtpEnabled) { - $this->smtpEnabled = false; - $this->syncData(true); - $this->dispatch('success', 'SMTP settings updated.'); - } else { - $this->validateSmtpSettings(); - $this->smtpEnabled = true; - $this->resendEnabled = false; - $this->submitSmtp(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } - } - - public function toggleResend() - { - try { - $this->resetErrorBag(); - - if ($this->resendEnabled) { - $this->resendEnabled = false; - $this->syncData(true); - $this->dispatch('success', 'Resend settings updated.'); - } else { - $this->validateResendSettings(); - $this->resendEnabled = true; - $this->smtpEnabled = false; - $this->submitResend(); - } - } catch (\Throwable $e) { - $this->syncData(); - - return handleError($e, $this); - } - } - public function submitSmtp() { try { $this->authorize('update', $this->settings); - $this->validateSmtpSettings(); - - if ($this->smtpEnabled) { - $this->settings->resend_enabled = $this->resendEnabled = false; - } + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -239,11 +210,17 @@ class SettingsEmail extends Component { try { $this->authorize('update', $this->settings); - $this->validateResendSettings(); - - if ($this->resendEnabled) { - $this->settings->smtp_enabled = $this->smtpEnabled = false; - } + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -260,45 +237,6 @@ class SettingsEmail extends Component } } - private function validateSmtpSettings(): void - { - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); - } - - private function validateResendSettings(): void - { - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); - } - public function sendTestEmail() { try { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 3b24d0cd2e..4082718191 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,89 +2,53 @@ namespace App\Livewire; -use App\Models\InstanceSettings; use App\Models\OauthSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Http\RedirectResponse; -use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { use AuthorizesRequests; - public InstanceSettings $settings; - public $oauth_settings_map; - public ?string $selectedProvider = null; - - public bool $disable_registration_when_oauth_enabled = false; - - protected function rules(): array + protected function rules() { - return $this->validationRules(); - } - - private function validationRules(?string $provider = null): array - { - $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) { - if ($provider !== null && $setting->provider !== $provider) { - return $carry; - } - - $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean'; - $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string'; - $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string'; - $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https'; - $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string'; - $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https'; - $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255'; - $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000'; - $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean'; - $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600'; + return OauthSetting::all()->reduce(function ($carry, $setting) { + $carry["oauth_settings_map.$setting->provider.enabled"] = 'required'; + $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable'; + $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable'; return $carry; }, []); - - if ($provider === null) { - $rules['disable_registration_when_oauth_enabled'] = 'boolean'; - } - - return $rules; } - public function mount(?string $provider = null): ?RedirectResponse + public function mount() { if (! isInstanceAdmin()) { return redirect()->route('home'); } + $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { + $carry[$setting->provider] = [ + 'id' => $setting->id, + 'provider' => $setting->provider, + 'enabled' => $setting->enabled, + 'client_id' => $setting->client_id, + 'client_secret' => $setting->client_secret, + 'redirect_uri' => $setting->redirect_uri, + 'tenant' => $setting->tenant, + 'base_url' => $setting->base_url, + ]; - $this->settings = instanceSettings(); - $this->selectedProvider = $provider; - $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; - $this->oauth_settings_map = OauthSetting::all() - ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) - ->reduce(function ($carry, $setting) { - $carry[$setting->provider] = $this->oauthSettingToArray($setting); - - return $carry; - }, []); - - if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { - abort(404); - } - - return null; + return $carry; + }, []); } - private function updateOauthSettings(?string $provider = null): void + private function updateOauthSettings(?string $provider = null) { - $this->validate($this->validationRules($provider)); - if ($provider) { $oauthData = $this->oauth_settings_map[$provider]; $oauth = OauthSetting::find($oauthData['id']); @@ -93,128 +57,78 @@ class SettingsOauth extends Component throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - $this->fillOauthSetting($oauth, $oauthData); - $this->ensureProviderCanBeEnabled($oauth); + $oauth->fill([ + 'enabled' => $oauthData['enabled'], + 'client_id' => $oauthData['client_id'], + 'client_secret' => $oauthData['client_secret'], + 'redirect_uri' => $oauthData['redirect_uri'], + 'tenant' => $oauthData['tenant'], + 'base_url' => $oauthData['base_url'], + ]); + + if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { + $oauth->update(['enabled' => false]); + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + } $oauth->save(); - $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + // Update the array with fresh data + $this->oauth_settings_map[$provider] = [ + 'id' => $oauth->id, + 'provider' => $oauth->provider, + 'enabled' => $oauth->enabled, + 'client_id' => $oauth->client_id, + 'client_secret' => $oauth->client_secret, + 'redirect_uri' => $oauth->redirect_uri, + 'tenant' => $oauth->tenant, + 'base_url' => $oauth->base_url, + ]; $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); + } else { + $errors = []; + foreach (array_values($this->oauth_settings_map) as $settingData) { + $oauth = OauthSetting::find($settingData['id']); - return; - } + if (! $oauth) { + $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; - $errors = []; - foreach (array_values($this->oauth_settings_map) as $settingData) { - $oauth = OauthSetting::find($settingData['id']); + continue; + } - if (! $oauth) { - $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; + $oauth->fill([ + 'enabled' => $settingData['enabled'], + 'client_id' => $settingData['client_id'], + 'client_secret' => $settingData['client_secret'], + 'redirect_uri' => $settingData['redirect_uri'], + 'tenant' => $settingData['tenant'], + 'base_url' => $settingData['base_url'], + ]); - continue; + if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) { + $oauth->enabled = false; + $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + } + + $oauth->save(); + + // Update the array with fresh data + $this->oauth_settings_map[$oauth->provider] = [ + 'id' => $oauth->id, + 'provider' => $oauth->provider, + 'enabled' => $oauth->enabled, + 'client_id' => $oauth->client_id, + 'client_secret' => $oauth->client_secret, + 'redirect_uri' => $oauth->redirect_uri, + 'tenant' => $oauth->tenant, + 'base_url' => $oauth->base_url, + ]; } - $this->fillOauthSetting($oauth, $settingData); - - if ($oauth->enabled && ! $oauth->couldBeEnabled()) { - $oauth->enabled = false; - $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + if (! empty($errors)) { + $this->dispatch('error', implode('
', $errors)); } - - if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { - $oauth->enabled = false; - $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled."; - } - - $oauth->save(); - $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); } - - instanceSettings()->update([ - 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, - ]); - - if (! empty($errors)) { - $this->dispatch('error', implode('
', $errors)); - } - } - - private function fillOauthSetting(OauthSetting $oauth, array $data): void - { - $oauth->fill([ - 'enabled' => (bool) ($data['enabled'] ?? false), - 'client_id' => $data['client_id'] ?? null, - 'client_secret' => $data['client_secret'] ?? null, - 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null), - 'tenant' => $data['tenant'] ?? null, - 'base_url' => $this->nullableString($data['base_url'] ?? null), - 'custom_label' => $data['custom_label'] ?? null, - 'scopes' => $data['scopes'] ?? null, - 'allow_registration' => (bool) ($data['allow_registration'] ?? false), - 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false), - 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true), - 'use_pkce' => (bool) ($data['use_pkce'] ?? true), - 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60), - ]); - } - - private function nullableString(mixed $value): ?string - { - if ($value === null) { - return null; - } - - $value = trim((string) $value); - - return $value === '' ? null : $value; - } - - private function ensureProviderCanBeEnabled(OauthSetting $oauth): void - { - if (! $oauth->enabled) { - return; - } - - if (! $oauth->couldBeEnabled()) { - $oauth->update(['enabled' => false]); - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); - } - - if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { - $oauth->update(['enabled' => false]); - throw new \Exception("OIDC scopes must include 'openid'."); - } - } - - private function oauthSettingToArray(OauthSetting $setting): array - { - return [ - 'id' => $setting->id, - 'provider' => $setting->provider, - 'enabled' => $setting->enabled, - 'client_id' => $setting->client_id, - 'client_secret' => $setting->client_secret, - 'redirect_uri' => $setting->redirect_uri, - 'tenant' => $setting->tenant, - 'base_url' => $setting->base_url, - 'custom_label' => $setting->custom_label, - 'scopes' => $setting->scopes ?: 'openid email profile', - 'allow_registration' => $setting->allow_registration, - 'auto_join_root_team' => $setting->auto_join_root_team, - 'require_email_verified' => $setting->require_email_verified ?? true, - 'use_pkce' => $setting->use_pkce ?? true, - 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60, - 'label' => $this->providerLabel($setting->provider), - ]; - } - - public function providerLabel(string $provider): string - { - return match ($provider) { - 'oidc' => 'OpenID Connect', - 'gitlab' => 'GitLab', - default => str($provider)->headline()->toString(), - }; } public function instantSave(string $provider) @@ -227,88 +141,56 @@ class SettingsOauth extends Component } } - public function toggleProvider(string $provider) + public function toggleProvider(string $provider): mixed { try { $this->authorize('update', instanceSettings()); if (! array_key_exists($provider, $this->oauth_settings_map)) { - abort(404); + throw new \Exception('OAuth provider not found.'); } - if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { - $this->validateProviderCanBeEnabled($provider); + $enabling = ! $this->oauth_settings_map[$provider]['enabled']; + if ($enabling) { + $this->validate($this->providerRules($provider)); } - $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; + $this->oauth_settings_map[$provider]['enabled'] = $enabling; $this->updateOauthSettings($provider); - } catch (\Exception $e) { - $oauth = OauthSetting::where('provider', $provider)->first(); - if ($oauth) { - $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); - } - + } catch (\Throwable $e) { return handleError($e, $this); } + + return null; } - private function validateProviderCanBeEnabled(string $provider): void + private function providerRules(string $provider): array { - $this->validate($this->validationRules($provider)); + $prefix = "oauth_settings_map.$provider"; + $rules = [ + "$prefix.client_id" => 'required', + "$prefix.client_secret" => 'required', + ]; - $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']); - if (! $oauth) { - throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); + if ($provider === 'azure') { + $rules["$prefix.tenant"] = 'required'; } - $this->fillOauthSetting($oauth, [ - ...$this->oauth_settings_map[$provider], - 'enabled' => true, - ]); - - if (! $oauth->couldBeEnabled()) { - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + if (in_array($provider, ['authentik', 'clerk'], true)) { + $rules["$prefix.base_url"] = 'required'; } - if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { - throw new \Exception("OIDC scopes must include 'openid'."); - } + return $rules; } - public function saveRegistrationPolicy(): void - { - $this->authorize('update', instanceSettings()); - $this->validate([ - 'disable_registration_when_oauth_enabled' => 'boolean', - ]); - - instanceSettings()->update([ - 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, - ]); - - $this->dispatch('success', 'Authentication settings updated successfully!'); - } - - public function submit(): void + public function submit() { try { $this->authorize('update', instanceSettings()); - $this->updateOauthSettings($this->selectedProvider); - - if ($this->selectedProvider === null) { - $this->dispatch('success', 'Instance settings updated successfully!'); - } - } catch (ValidationException $e) { - throw $e; - } catch (\Exception $e) { - if ($this->selectedProvider !== null) { - $oauth = OauthSetting::where('provider', $this->selectedProvider)->first(); - if ($oauth) { - $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth); - } - } - - handleError($e, $this); + $this->updateOauthSettings(); + $this->dispatch('success', 'Instance settings updated successfully!'); + } catch (\Throwable $e) { + return handleError($e, $this); } } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 70c9013af2..89188b31b1 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -302,23 +302,6 @@ class EnvironmentVariable extends BaseModel return $real_value; } - public function resolveReferencedValue(): ?string - { - $value = $this->value; - - if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { - return $value; - } - - $referencedKey = str($value)->after('$')->trim('{}')->value(); - - return static::where('resourceable_type', $this->resourceable_type) - ->where('resourceable_id', $this->resourceable_id) - ->where('is_preview', (bool) $this->is_preview) - ->where('key', $referencedKey) - ->first()?->value ?? $value; - } - private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 02f3e7ed50..eb01fa7ada 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -22,7 +22,6 @@ class InstanceSettings extends Model 'do_not_track', 'is_auto_update_enabled', 'is_registration_enabled', - 'disable_registration_when_oauth_enabled', 'next_channel', 'smtp_enabled', 'smtp_from_address', @@ -89,8 +88,6 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', - 'is_registration_enabled' => 'boolean', - 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', @@ -118,19 +115,6 @@ class InstanceSettings extends Model }); } - public function isPasswordRegistrationAllowed(): bool - { - if (! $this->is_registration_enabled) { - return false; - } - - if (! $this->disable_registration_when_oauth_enabled) { - return true; - } - - return ! OauthSetting::where('enabled', true)->exists(); - } - public function fqdn(): Attribute { return Attribute::make( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php deleted file mode 100644 index 20541f6139..0000000000 --- a/app/Models/IntegrationToken.php +++ /dev/null @@ -1,38 +0,0 @@ - 'encrypted', - 'capabilities' => 'array', - ]; - } - - public function team(): BelongsTo - { - return $this->belongsTo(Team::class); - } - - public static function ownedByCurrentTeam() - { - return self::query()->where('team_id', currentTeam()->id); - } -} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php deleted file mode 100644 index 1edf71ad2f..0000000000 --- a/app/Models/OauthIdentity.php +++ /dev/null @@ -1,35 +0,0 @@ - 'array', - 'last_login_at' => 'datetime', - ]; - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } -} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 7765e41160..e7999134a6 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,19 +11,7 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; - - protected function casts(): array - { - return [ - 'enabled' => 'boolean', - 'allow_registration' => 'boolean', - 'auto_join_root_team' => 'boolean', - 'require_email_verified' => 'boolean', - 'use_pkce' => 'boolean', - 'clock_skew_seconds' => 'integer', - ]; - } + protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; protected $hidden = [ 'client_secret', @@ -44,46 +32,9 @@ class OauthSetting extends Model return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': case 'clerk': - case 'oidc': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); } } - - /** - * @return array - */ - public function scopeList(): array - { - $scopes = str($this->scopes ?: 'openid email profile') - ->replace(',', ' ') - ->explode(' ') - ->map(fn (string $scope) => trim($scope)) - ->filter() - ->unique() - ->values() - ->all(); - - return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes; - } - - public function loginLabel(): string - { - if (filled($this->custom_label)) { - return $this->custom_label; - } - - $envLabel = config("services.{$this->provider}.custom_label"); - if (filled($envLabel)) { - return $envLabel; - } - - return __("auth.login.{$this->provider}"); - } - - public function isOidc(): bool - { - return $this->provider === 'oidc'; - } } diff --git a/app/Models/Team.php b/app/Models/Team.php index b7664e94d3..15085203aa 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -304,11 +304,6 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } - public function integrationTokens() - { - return $this->hasMany(IntegrationToken::class); - } - public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index 10303422bd..5b38473962 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,7 +11,6 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -508,26 +507,12 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } - public function oauthIdentities(): HasMany - { - return $this->hasMany(OauthIdentity::class); - } - - public function hasSsoIdentity(): bool - { - return $this->oauthIdentities()->exists(); - } - /** * Check if the user has a password set. + * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } - - public function requiresPasswordConfirmation(): bool - { - return $this->hasPassword() && ! $this->hasSsoIdentity(); - } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php deleted file mode 100644 index 309c8167f2..0000000000 --- a/app/Policies/IntegrationTokenPolicy.php +++ /dev/null @@ -1,34 +0,0 @@ -isAdmin(); - } - - public function create(User $user): bool - { - return $user->isAdmin(); - } - - public function view(User $user, IntegrationToken $integrationToken): bool - { - return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; - } - - public function update(User $user, IntegrationToken $integrationToken): bool - { - return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; - } - - public function delete(User $user, IntegrationToken $integrationToken): bool - { - return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e4d2b0a851..5856791662 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,6 @@ namespace App\Providers; -use App\Auth\Oidc\OidcDiscoveryService; -use App\Auth\Oidc\OidcTokenValidator; -use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -13,7 +10,6 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; -use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -26,11 +22,12 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); + $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - $this->configureOidcSocialite(); + } private function configureCommands(): void @@ -65,24 +62,6 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } - private function configureOidcSocialite(): void - { - if (! $this->app->bound(SocialiteFactory::class)) { - return; - } - - $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { - return new OidcProvider( - $app['request'], - $app->make(OidcDiscoveryService::class), - $app->make(OidcTokenValidator::class), - '', - '', - '', - ); - }); - } - private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -98,5 +77,16 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); + + Http::macro('GitLab', function (string $api_url, ?string $access_token = null) { + $client = Http::withHeaders([ + 'Accept' => 'application/json', + ])->baseUrl($api_url); + if ($access_token) { + $client = $client->withToken($access_token); + } + + return $client; + }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e8e6fb42c6..09b2a3e089 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,7 +15,6 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; -use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -53,7 +52,6 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; -use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -134,7 +132,6 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, - IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php new file mode 100644 index 0000000000..07e0e8709f --- /dev/null +++ b/app/Providers/DuskServiceProvider.php @@ -0,0 +1,21 @@ +visit('/login') + ->type('email', 'test@example.com') + ->type('password', 'password') + ->press('Login'); + }); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index dfa3bb3314..65d9687744 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->isPasswordRegistrationAllowed()) { + if (! $settings->is_registration_enabled) { return redirect()->route('login'); } @@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { - // If there are no users and password registration is allowed, redirect to registration. + if ($users == 0) { + // If there are no users, redirect to registration return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), + 'is_registration_enabled' => $settings->is_registration_enabled, 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php deleted file mode 100644 index 2ec8f88e3e..0000000000 --- a/app/Services/Auth/OauthLoginService.php +++ /dev/null @@ -1,228 +0,0 @@ -email)); - if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { - throw new HttpException(403, 'OAuth provider did not return a valid email address'); - } - - $user = $provider === 'oidc' - ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) - : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); - - Auth::login($user); - $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); - session(['currentTeam' => $user->currentTeam = $team]); - - return $user; - } - - private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User - { - $provider = $oauthSetting->provider; - $providerUserId = $oauthUser->id ?? null; - if ( - (! is_string($providerUserId) && ! is_int($providerUserId)) - || (is_string($providerUserId) && trim($providerUserId) === '') - ) { - throw new HttpException(403, 'OAuth provider did not return a valid user ID'); - } - $providerUserId = (string) $providerUserId; - $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; - - $identityKey = [ - 'provider' => $provider, - 'issuer' => $provider, - 'provider_user_id' => $providerUserId, - ]; - - try { - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { - $identity = OauthIdentity::where($identityKey)->first(); - - if ($identity) { - $identity->update([ - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $identity->user; - } - - $user = User::whereEmail($email)->first(); - if (! $user) { - if (! $this->canCreateUser($oauthSetting)) { - throw new HttpException(403, 'Registration is disabled'); - } - - $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); - } - - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => $provider, - 'issuer' => $provider, - 'provider_user_id' => $providerUserId, - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $user; - }); - } catch (UniqueConstraintViolationException $exception) { - return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; - } - } - - private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User - { - $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) - ? $oauthUser->issuer - : data_get($oauthUser->user, 'iss'); - $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) - ? $oauthUser->subject - : data_get($oauthUser->user, 'sub', $oauthUser->id); - $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) - || data_get($oauthUser->user, 'email_verified') === true; - - if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { - throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); - } - - if ($oauthSetting->require_email_verified && ! $emailVerified) { - throw new HttpException(403, 'OIDC provider did not verify the email address'); - } - - $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; - - $identityKey = [ - 'provider' => 'oidc', - 'issuer' => $issuer, - 'provider_user_id' => $subject, - ]; - - try { - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { - $identity = OauthIdentity::where($identityKey)->first(); - - if ($identity) { - $identity->update([ - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $identity->user; - } - - $user = User::whereEmail($email)->first(); - - // Linking a new OIDC identity to an existing local account by email - // is account takeover unless the provider attests the email. This - // guard is independent of the require_email_verified toggle, which - // only governs the broader login flow. - if ($user && ! $emailVerified) { - throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); - } - - if (! $user) { - if (! $this->canCreateUser($oauthSetting)) { - throw new HttpException(403, 'Registration is disabled'); - } - - $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); - } - - OauthIdentity::create([ - 'user_id' => $user->id, - 'provider' => 'oidc', - 'issuer' => $issuer, - 'provider_user_id' => $subject, - 'email' => $email, - 'raw_claims' => $rawClaims, - 'last_login_at' => now(), - ]); - - return $user; - }); - } catch (UniqueConstraintViolationException $exception) { - return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; - } - } - - private function canCreateUser(OauthSetting $oauthSetting): bool - { - return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; - } - - private function createUser(string $name, string $email, OauthSetting $oauthSetting): User - { - if (User::count() === 0) { - $user = (new User)->forceFill([ - 'id' => 0, - 'name' => $name, - 'email' => $email, - 'password' => Hash::make(Str::random(64)), - ]); - $user->save(); - - $team = $user->teams()->first() ?? Team::find(0); - if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { - $user->teams()->attach($team, ['role' => 'owner']); - } - - instanceSettings()->update(['is_registration_enabled' => false]); - - return $user; - } - - if ($oauthSetting->auto_join_root_team) { - return $this->createRootTeamOnlyUser($name, $email); - } - - return User::create([ - 'name' => $name, - 'email' => $email, - 'password' => Hash::make(Str::random(64)), - ]); - } - - private function createRootTeamOnlyUser(string $name, string $email): User - { - return DB::transaction(function () use ($name, $email) { - $rootTeam = Team::find(0); - if ($rootTeam === null) { - throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); - } - - $user = User::withoutEvents(fn () => User::create([ - 'name' => $name, - 'email' => $email, - 'password' => Hash::make(Str::random(64)), - ])); - - $user->teams()->attach($rootTeam, ['role' => 'member']); - - return $user; - }); - } -} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php deleted file mode 100644 index 2a4a761027..0000000000 --- a/app/Services/CloudflareTokenValidator.php +++ /dev/null @@ -1,42 +0,0 @@ -client($token); - $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); - - if (! $verification->successful() || $verification->json('result.status') !== 'active') { - return false; - } - - if (in_array('dns', $capabilities, true)) { - $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); - $zoneId = $zones->json('result.0.id'); - - if (! $zones->successful() || ! is_string($zoneId)) { - return false; - } - - return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ - 'per_page' => 1, - ])->successful(); - } - - return true; - } - - private function client(string $token): PendingRequest - { - return Http::withToken($token) - ->acceptJson() - ->connectTimeout(5) - ->timeout(10); - } -} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 461e7c2669..8a003ec40d 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no usable local password confirmation (including SSO users) + * - User has no password (OAuth users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4566,9 +4566,8 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // OAuth users may have an unusable generated password, so the linked - // identity is the source of truth for whether confirmation is possible. - if (! Auth::user()?->requiresPasswordConfirmation()) { + // Skip if user has no password (OAuth users) + if (! Auth::user()?->hasPassword()) { return true; } @@ -4579,7 +4578,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no usable local password confirmation (including SSO users) + * - User has no password (OAuth users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index f177e6c16f..fd3fbe74ba 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,13 +1,7 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -29,7 +23,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new Config( + $authentik_clerk_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -40,7 +34,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new Config( + $zitadel_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -50,12 +44,8 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } - if ($provider === 'oidc') { - return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); - } - if ($provider == 'google') { - $google_config = new Config( + $google_config = new \SocialiteProviders\Manager\Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -73,11 +63,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => BitbucketProvider::class, - 'discord' => Provider::class, - 'github' => GithubProvider::class, - 'gitlab' => GitlabProvider::class, - 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, + 'discord' => \SocialiteProviders\Discord\Provider::class, + 'github' => \Laravel\Socialite\Two\GithubProvider::class, + 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, + 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/composer.json b/composer.json index c0ffc6f07f..871c6f010c 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,6 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", - "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", @@ -64,6 +63,7 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", + "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index b77aef46f5..c2c42ba71a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", + "content-hash": "971daeb1b3078a36428c0fb56bb895b7", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,6 +13698,80 @@ }, "time": "2026-05-19T20:09:50+00:00" }, + { + "name": "laravel/dusk", + "version": "v8.6.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/dusk.git", + "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", + "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.5", + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "php-webdriver/webdriver": "^1.15.2", + "symfony/console": "^6.2|^7.0|^8.0", + "symfony/finder": "^6.2|^7.0|^8.0", + "symfony/process": "^6.2|^7.0|^8.0", + "vlucas/phpdotenv": "^5.2" + }, + "require-dev": { + "laravel/framework": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.6", + "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.1|^11.0|^12.0.1", + "psy/psysh": "^0.11.12|^0.12", + "symfony/yaml": "^6.2|^7.0|^8.0" + }, + "suggest": { + "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Dusk\\DuskServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Dusk\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", + "keywords": [ + "laravel", + "testing", + "webdriver" + ], + "support": { + "issues": "https://github.com/laravel/dusk/issues", + "source": "https://github.com/laravel/dusk/tree/v8.6.0" + }, + "time": "2026-04-15T14:50:40+00:00" + }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14743,6 +14817,72 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "php-webdriver/webdriver", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/php-webdriver/php-webdriver.git", + "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", + "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-zip": "*", + "php": "^7.3 || ^8.0", + "symfony/polyfill-mbstring": "^1.12", + "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "replace": { + "facebook/webdriver": "*" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.20.0", + "ondram/ci-detector": "^4.0", + "php-coveralls/php-coveralls": "^2.4", + "php-mock/php-mock-phpunit": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpunit/phpunit": "^9.3", + "squizlabs/php_codesniffer": "^3.5", + "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-simplexml": "For Firefox profile creation" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Exception/TimeoutException.php" + ], + "psr-4": { + "Facebook\\WebDriver\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", + "homepage": "https://github.com/php-webdriver/php-webdriver", + "keywords": [ + "Chromedriver", + "geckodriver", + "php", + "selenium", + "webdriver" + ], + "support": { + "issues": "https://github.com/php-webdriver/php-webdriver/issues", + "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" + }, + "time": "2025-12-28T23:57:40+00:00" + }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 59aa6f4c28..13a5b7d4b8 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), - 'store' => env('APP_MAINTENANCE_STORE', 'redis'), + 'driver' => 'cache', + 'store' => 'redis', ], /* diff --git a/config/services.php b/config/services.php index 3a2a0631ef..c5956cf6c9 100644 --- a/config/services.php +++ b/config/services.php @@ -60,14 +60,6 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], - 'oidc' => [ - 'client_id' => env('OIDC_CLIENT_ID'), - 'client_secret' => env('OIDC_CLIENT_SECRET'), - 'redirect' => env('OIDC_REDIRECT_URI'), - 'base_url' => env('OIDC_BASE_URL'), - 'custom_label' => env('OIDC_LOGIN_LABEL'), - ], - 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 13fe6b6784..19c4445b26 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,12 +8,6 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. - * - * Coolify's own backend runs exclusively on PostgreSQL in production and - * SQLite in testing (see config/database.php — the only configured - * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed - * resources, never Coolify's application database, so no driver path is - * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php deleted file mode 100644 index 3160ef9ddb..0000000000 --- a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php +++ /dev/null @@ -1,40 +0,0 @@ -string('custom_label')->nullable(); - $table->string('scopes')->nullable(); - $table->boolean('allow_registration')->default(true); - $table->boolean('require_email_verified')->default(true); - $table->boolean('use_pkce')->default(true); - $table->unsignedSmallInteger('clock_skew_seconds')->default(60); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('oauth_settings', function (Blueprint $table) { - $table->dropColumn([ - 'custom_label', - 'scopes', - 'allow_registration', - 'require_email_verified', - 'use_pkce', - 'clock_skew_seconds', - ]); - }); - } -}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php deleted file mode 100644 index 9f838e5779..0000000000 --- a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('provider'); - $table->string('issuer'); - $table->string('provider_user_id'); - $table->string('email')->nullable()->index(); - $table->json('raw_claims')->nullable(); - $table->timestamp('last_login_at')->nullable(); - $table->timestamps(); - - $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('oauth_identities'); - } -}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php deleted file mode 100644 index 06c0f1dd52..0000000000 --- a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('disable_registration_when_oauth_enabled')->default(false); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('disable_registration_when_oauth_enabled'); - }); - } -}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php deleted file mode 100644 index b0f5aad18a..0000000000 --- a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -boolean('auto_join_root_team')->default(false); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('oauth_settings', function (Blueprint $table) { - $table->dropColumn('auto_join_root_team'); - }); - } -}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php deleted file mode 100644 index a17d3972d5..0000000000 --- a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php +++ /dev/null @@ -1,29 +0,0 @@ -id(); - $table->string('uuid')->unique(); - $table->foreignId('team_id')->constrained()->cascadeOnDelete(); - $table->string('provider'); - $table->string('name'); - $table->text('token'); - $table->json('capabilities'); - $table->timestamps(); - - $table->index(['team_id', 'provider']); - }); - } - - public function down(): void - { - Schema::dropIfExists('integration_tokens'); - } -}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index f916c4a9cd..2e3e63defd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,7 +23,6 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', - 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 19d3aa42e8..2ac615cc01 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,10 +15,12 @@ class UserSeeder extends Seeder 'email' => 'test@example.com', ]); User::factory()->create([ + 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ + 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); diff --git a/lang/de.json b/lang/de.json index cbc2237a75..7c43300e67 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,7 +7,6 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", - "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index b97a10d629..12c21b6665 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,7 +8,6 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", - "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index b05437ac4e..bcd8e23937 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,7 +8,6 @@ "auth.login.github": "Zaloguj się przez GitHub", "auth.login.gitlab": "Zaloguj się przez Gitlab", "auth.login.google": "Zaloguj się przez Google", - "auth.login.oidc": "Zaloguj się przez SSO", "auth.login.infomaniak": "Zaloguj się przez Infomaniak", "auth.login.zitadel": "Zaloguj się przez Zitadel", "auth.already_registered": "Już zarejestrowany?", diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg deleted file mode 100644 index 9c542584ef..0000000000 --- a/public/svgs/oidc.svg +++ /dev/null @@ -1,5 +0,0 @@ - - OpenID Connect - - - diff --git a/resources/js/app.js b/resources/js/app.js index 900ef8af71..bb41b7f041 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,3 @@ -import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeTerminalComponent } from './terminal.js'; // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate @@ -13,7 +12,6 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); -document.addEventListener('alpine:init', initializeCopyButtonComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js deleted file mode 100644 index 0ce8d5d67d..0000000000 --- a/resources/js/copy-button.js +++ /dev/null @@ -1,35 +0,0 @@ -// Alpine data provider for the component (x-data="copyButton"). -export function initializeCopyButtonComponent() { - window.Alpine.data('copyButton', () => ({ - copied: false, - async copy(value) { - if (value === null || value === undefined) { - window.toast('Value is not available.', { type: 'warning' }); - return; - } - try { - if (navigator.clipboard?.writeText && window.isSecureContext) { - await navigator.clipboard.writeText(value); - } else { - // Deprecated, but the only copy path on plain http (non-secure contexts). - const textarea = document.createElement('textarea'); - textarea.value = value; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const ok = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!ok) { - throw new Error('Copy command was rejected.'); - } - } - this.copied = true; - setTimeout(() => (this.copied = false), 1200); - } catch (e) { - window.toast('Could not copy to clipboard.', { type: 'warning' }); - } - }, - })); -} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 12eb57867c..829a26cad3 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,15 +80,11 @@ @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) - @if ($provider_setting->provider !== 'oidc') - - @endif - {{ $provider_setting->loginLabel() }} + {{ __("auth.login.$provider_setting->provider") }} @endforeach
diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php index 3333a62bfa..dfdceef20b 100644 --- a/resources/views/components/copy-button.blade.php +++ b/resources/views/components/copy-button.blade.php @@ -1,20 +1,22 @@ @props([ - 'value' => null, - 'resolve' => null, + 'value', 'label' => 'Copy to clipboard', ]) -@php - $valueExpression = $resolve ?? \Illuminate\Support\Js::from($value); -@endphp - - diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php new file mode 100644 index 0000000000..e299610eb2 --- /dev/null +++ b/resources/views/components/forms/copy-button.blade.php @@ -0,0 +1,28 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php deleted file mode 100644 index d31fac0bca..0000000000 --- a/resources/views/components/forms/copy-input.blade.php +++ /dev/null @@ -1,15 +0,0 @@ -@props(['text', 'label' => null]) - -
- @if ($label) - - @endif -
- - -
-
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index d0778dce4f..d63c1953f2 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -287,8 +287,17 @@
- +
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index a46e4194cf..04471497f5 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -63,7 +63,6 @@ 'upload' => '', 'x' => '', 'check' => '', - 'copy' => '', 'chevron-down' => '', 'trash' => '', 'external-link' => '', diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index a17b0b96a6..d2b3e30a6f 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -12,12 +12,6 @@ 'active' => request()->routeIs('security.cloud-tokens*'), 'icon' => 'cloud', ] : null, - auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [ - 'label' => 'Integration Tokens', - 'route' => 'security.integration-tokens', - 'active' => request()->routeIs('security.integration-tokens'), - 'icon' => 'network', - ] : null, auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ 'label' => 'Cloud-Init Scripts', 'route' => 'security.cloud-init-scripts', diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index dbe381e050..0e0de551fd 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -12,24 +12,6 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', ], - [ - 'label' => 'Authentication', - 'route' => 'settings.oauth', - 'active' => $activeMenu === 'oauth', - 'icon' => 'keys', - ], - [ - 'label' => 'Transactional Email', - 'route' => 'settings.email', - 'active' => $activeMenu === 'email', - 'icon' => 'notifications', - ], - [ - 'label' => 'Instance Backup', - 'route' => 'settings.backup', - 'active' => $activeMenu === 'backup', - 'icon' => 'database', - ], [ 'label' => 'Updates', 'route' => 'settings.updates', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 82b8cbcbdb..a97d8c1df7 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -225,6 +225,30 @@ let checkHealthInterval = null; let checkIfIamDeadInterval = null; + async function copyToClipboard(text) { + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!copied) { + throw new Error('Copy command was rejected.'); + } + } + window.Livewire.dispatch('success', 'Copied to clipboard.'); + } catch (error) { + window.Livewire.dispatch('error', 'Failed to copy to clipboard.'); + } + } + window.copyToClipboard = copyToClipboard; document.addEventListener('livewire:init', () => { window.Livewire.on('reloadWindow', (timeout) => { if (timeout) { diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index da5329a475..ef54d3e215 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -134,22 +134,15 @@
+ x-bind:disabled="emailModalOpen"> Change
- - + + - @if ($uses_sso) - - Signed in with SSO @if ($sso_provider_label) ({{ $sso_provider_label }}) @endif. Email is managed by your SSO provider. - - @endif - - @if (! $uses_sso) -
@@ -257,9 +249,9 @@
- - +
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 6997b766b8..8ab1442ba5 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 42ade3da6e..81c19bd3f0 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,9 +116,25 @@

Mount a Docker volume inside the container.

+ @if ($isSwarm) +
Swarm Mode detected: You need to set a shared + volume + (EFS/NFS/etc) on all the worker nodes if you would like to use a + persistent + volumes.
+ @endif
+ @if ($isSwarm) + + @else + + @endif diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 87ecd69985..923514efcc 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,8 +219,7 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" /> @endif @endforeach
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 5492d33f90..84d03c0fe8 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -28,10 +28,7 @@ - - - -
- @unless (auth()->user()?->isMember() ?? true) - - @endunless +
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php index 1a032f6964..2e92c73146 100644 --- a/resources/views/livewire/project/shared/resource-details.blade.php +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -3,8 +3,8 @@

Resource

- - + +
@@ -12,8 +12,8 @@

Environment

- - + +
@endif @@ -22,8 +22,8 @@

Project

- - + +
@endif @@ -32,8 +32,8 @@

Server

- - + +
@endif @@ -43,10 +43,10 @@

Stack Sub-Resources

@foreach ($stack_applications as $item) - + @endforeach @foreach ($stack_databases as $item) - + @endforeach
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index dbe21fd7b8..25a4fd7492 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,24 +154,7 @@
Source Path - @if (filled($form['hostPath'])) -
-
- -
- -
- @else - - - @endif +
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 40ea7b7e09..784843f6f0 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -71,7 +71,7 @@ - + diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index 6c87e3098d..c8c42763fa 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -39,7 +39,7 @@ - + @if ($githubManualWebhook && $gitlabManualWebhook) @@ -70,7 +70,7 @@

- + @can('update', $resource) - +
@endif diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 80647458df..38db6aa3a6 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -109,12 +109,7 @@ @if (session()->has('token')) -
- - -
+
@endif diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php deleted file mode 100644 index b7e53dbc7c..0000000000 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ /dev/null @@ -1,52 +0,0 @@ -
-
-
- - -
- -
-
- -
- Capabilities -
- -

- Manage Cloudflare DNS records. -

-
- @error('capabilities') - {{ $message }} - @enderror -
- - @if (in_array('dns', $capabilities, true)) -
-
Required Cloudflare permissions
-
    -
  • Zone - DNS - Edit
  • -
  • Zone - Zone - Read
  • -
- - Create a replacement token in Cloudflare - -
- @endif - -
- - - Validate and save - -
-
-
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php deleted file mode 100644 index d847fff7fb..0000000000 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ /dev/null @@ -1,49 +0,0 @@ -
-
- - -
- - -
- -
- Capabilities -
- -

- Manage Cloudflare DNS records. -

-
- @error('capabilities') - {{ $message }} - @enderror -
- - @if (in_array('dns', $capabilities, true)) -
-
Required Cloudflare permissions
-
    -
  • Zone - DNS - Edit
  • -
  • Zone - Zone - Read
  • -
-

Limit zone resources to the zones Coolify should manage.

- - Create this token in Cloudflare - -
- @endif - -
- - Validate and add - -
- -
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php deleted file mode 100644 index b4961551ae..0000000000 --- a/resources/views/livewire/security/integration-tokens.blade.php +++ /dev/null @@ -1,84 +0,0 @@ -
- - Integration Tokens | Coolify - - - -
- - - @can('create', App\Models\IntegrationToken::class) - - - - - - - @endcan - - - @if ($tokens->isEmpty()) - - @else -
- @foreach ($tokens as $savedToken) -
- - -
-
-

- -

-
-
- {{ ucfirst($savedToken->provider) }} -
-
- -
- -
-
- -
-
- @endforeach -
- @endif -
-
-
-
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 2279e62e39..94d2050dc2 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -34,7 +34,7 @@

Read-only bind mount

-
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index f1e4fc3f7a..d490b6f1db 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status - notifications can be managed from + Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications + can be managed from notification settings. diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 822c035b31..97822b9251 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -5,126 +5,76 @@ -
- -
+
+ +
-
+ @foreach ($oauth_settings_map as $oauth_setting) + @php + $provider = $oauth_setting['provider']; + $providerLabel = str($provider)->headline(); + @endphp - - - - - @foreach ($oauth_settings_map as $provider => $oauth_setting) + title="{{ $providerLabel }}">
- + if (!enabled) { + const invalidField = [...$el.closest('section').querySelectorAll('[required]')] + .find(field => !field.checkValidity()); + if (invalidField) { invalidField.reportValidity(); return; } + } + $wire.toggleProvider(provider); + "> {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
-
- @if ($provider === 'oidc') - - - - - - -
- -
- @else - - - - @endif + + + + @if ($provider === 'azure') - + @endif @if ($provider === 'google') - @endif @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) - + @endif - -
- -
- @if ($provider === 'oidc') - - - - @endif -
@endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index d05ac5ac98..d15a1b87ab 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -13,19 +13,12 @@
- - + ]" /> {{ $invite->link }} - +
', false); - - Livewire::test(SettingsOauth::class) - ->set('disable_registration_when_oauth_enabled', true) - ->call('saveRegistrationPolicy') - ->assertHasNoErrors() - ->assertDispatched('success'); - - expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); -}); - -it('shows oidc fields with a naked okta issuer url example', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('OpenID Connect') - ->assertSee('https://example.okta.com', false) - ->assertDontSee('/oauth2/default', false); -}); - -it('groups oidc fields in the expected desktop order', function () { - $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); - $fields = [ - 'redirect_uri', - 'base_url', - 'client_id', - 'client_secret', - 'scopes', - 'clock_skew_seconds', - 'custom_label', - ]; - $positions = array_map( - fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), - $fields, - ); - - expect($positions)->not->toContain(false) - ->and($positions)->toBe(collect($positions)->sort()->values()->all()) - ->and($view)->toContain('
'); -}); - -it('shows provider enable controls as settings section actions', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Enable') - ->assertDontSee('label="Enabled"', false) - ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); -}); - -it('stacks oidc option checkboxes vertically', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth')) - ->assertSuccessful() - ->assertSee('Allow OIDC user creation') - ->assertSee('Require verified email') - ->assertSee('Use PKCE') - ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false); -}); - -it('does not show unknown oauth providers', function () { - actingAsInstanceAdmin(); - - $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get('/settings/oauth/unknown') - ->assertNotFound(); -}); - -it('defaults oidc user creation and verified email requirement to enabled', function () { - $setting = OauthSetting::where('provider', 'oidc')->first(); - - expect($setting->allow_registration)->toBeTrue() - ->and($setting->require_email_verified)->toBeTrue() - ->and($setting->auto_join_root_team)->toBeFalse(); -}); - -it('persists oidc oauth settings from livewire', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.enabled', true) - ->set('oauth_settings_map.oidc.client_id', 'client-id') - ->set('oauth_settings_map.oidc.client_secret', 'secret') - ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback') - ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') - ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups') - ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta') - ->set('oauth_settings_map.oidc.allow_registration', true) - ->set('oauth_settings_map.oidc.auto_join_root_team', true) - ->set('oauth_settings_map.oidc.require_email_verified', true) - ->set('disable_registration_when_oauth_enabled', true) - ->call('submit') - ->assertHasNoErrors(); - - $setting = OauthSetting::where('provider', 'oidc')->first(); - expect($setting->enabled)->toBeTrue() - ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback') - ->and($setting->base_url)->toBe('https://idp.example.com') - ->and($setting->custom_label)->toBe('Login with Okta') - ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) - ->and($setting->allow_registration)->toBeTrue() - ->and($setting->auto_join_root_team)->toBeTrue(); - - expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); -}); - -it('saves only the selected provider from provider pages', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url') - ->set('oauth_settings_map.authentik.enabled', true) - ->set('oauth_settings_map.authentik.client_id', 'authentik-client') - ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') - ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') - ->call('submit') - ->assertHasNoErrors(); - - $setting = OauthSetting::where('provider', 'authentik')->first(); - expect($setting->enabled)->toBeTrue() - ->and($setting->client_id)->toBe('authentik-client') - ->and($setting->base_url)->toBe('https://authentik.example.com'); -}); - -it('validates oidc url fields before saving', function (string $field, string $value) { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.client_id', 'client-id') - ->set('oauth_settings_map.oidc.client_secret', 'secret') - ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') - ->set("oauth_settings_map.oidc.$field", $value) - ->call('submit') - ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']); - - $setting = OauthSetting::where('provider', 'oidc')->first(); - expect($setting->{$field})->toBeNull(); -})->with([ - 'invalid redirect uri' => ['redirect_uri', 'not-a-url'], - 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'], - 'invalid issuer url' => ['base_url', 'not-a-url'], - 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'], -]); - -it('does not enable oidc without required fields', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class) - ->set('oauth_settings_map.oidc.enabled', true) - ->call('instantSave', 'oidc') - ->assertDispatched('error'); - - expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse(); -}); - -it('keeps provider disabled in the ui when enable validation fails', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->call('toggleProvider', 'authentik') - ->assertDispatched('error') - ->assertSet('oauth_settings_map.authentik.enabled', false); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); -}); - -it('disables an enabled provider gracefully when required fields become incomplete', function () { - actingAsInstanceAdmin(); - - OauthSetting::where('provider', 'authentik')->first()->forceFill([ - 'enabled' => true, - 'client_id' => 'authentik-client', - 'client_secret' => 'authentik-secret', - 'base_url' => 'https://authentik.example.com', - ])->save(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.authentik.client_secret', '') - ->call('submit') - ->assertDispatched('error') - ->assertSet('oauth_settings_map.authentik.enabled', false); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); -}); - -it('toggles provider enabled state from the action button', function () { - actingAsInstanceAdmin(); - - Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) - ->set('oauth_settings_map.authentik.client_id', 'authentik-client') - ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') - ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') - ->call('toggleProvider', 'authentik') - ->assertHasNoErrors(); - - expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue(); -}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 272156fbd2..45e150dfab 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") + ->toContain("'bash -se' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 949a301923..13b6de23e5 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -51,21 +51,27 @@ it('renders a real copy button for pending invitation links', function () { $view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php')); expect($view) - ->toContain(''); + ->toContain('aria-label="Copy invitation link"') + ->toContain('window.copyToClipboard(@js($invite->link))') + ->toContain('class="button h-7! shrink-0 px-2!"'); Livewire::test(Invitations::class, [ 'invitations' => TeamInvitation::ownedByCurrentTeam()->get(), ]) ->assertSee($invitation->link) ->assertSeeHtml('aria-label="Copy invitation link"') - ->assertSeeHtml('x-data="copyButton"') + ->assertSeeHtml('window.copyToClipboard(') ->assertSeeHtml('type="button"'); }); -it('keeps clipboard logic in the shared copy button instead of a global helper', function () { +it('exposes a resilient global copyToClipboard helper', function () { $layout = file_get_contents(resource_path('views/layouts/base.blade.php')); - expect($layout)->not->toContain('copyToClipboard'); + expect($layout) + ->toContain('async function copyToClipboard(text)') + ->toContain('window.copyToClipboard = copyToClipboard') + ->toContain('document.execCommand(\'copy\')') + ->toContain('window.isSecureContext'); }); it('preserves a provisional user when revoking their invitation fails', function () { diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php deleted file mode 100644 index d8ccf86510..0000000000 --- a/tests/Feature/UserSeederTest.php +++ /dev/null @@ -1,16 +0,0 @@ -seed(UserSeeder::class); - - $user = User::factory()->create(); - - expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) - ->and($user->id)->toBe(3); -}); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php deleted file mode 100644 index d8050c84d9..0000000000 --- a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php +++ /dev/null @@ -1,62 +0,0 @@ -invoke(new InstallPrerequisites); - - expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); -}); - -it('installs every Docker CLI plugin required on Alpine', function () { - $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); - - $command = $method->invoke(new InstallDocker); - - expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); -}); - -it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { - $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); - - $action = new InstallDocker; - $commands = $method->invoke($action, true); - - expect($commands) - ->toBe(['rc-update add docker default', 'rc-service docker restart']) - ->each->not->toContain('systemctl') - ->and($method->invoke($action, false)) - ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); -}); - -it('parses Alpine package updates', function () { - $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); - $output = <<<'OUTPUT' -docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] -libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] -OUTPUT; - - $result = $method->invoke(new CheckUpdates, $output); - - expect($result)->toBe([ - 'total_updates' => 2, - 'updates' => [ - [ - 'package' => 'docker-cli-compose', - 'new_version' => '2.31.0-r5', - 'architecture' => 'x86_64', - 'current_version' => '2.31.0-r4', - ], - [ - 'package' => 'libcrypto3', - 'new_version' => '3.3.4-r0', - 'architecture' => 'aarch64', - 'current_version' => '3.3.3-r0', - ], - ], - ]); -}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 140be57643..b7901abb68 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -334,13 +334,13 @@ it('detects environment variable value changes without exposing secret values', $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); expect($change)->not->toBeNull() - ->and($change['display_summary'])->toBeNull() - ->and($change['old_display_value'])->toBe('old-secret') - ->and($change['new_display_value'])->toBe('new-secret') - ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret'); + ->and($change['display_summary'])->toBe('Changed') + ->and($change['old_display_value'])->toBe('••••••••') + ->and($change['new_display_value'])->toBe('••••••••') + ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); }); -it('describes added unlocked environment variables with their value', function () { +it('describes added environment variables as set without exposing secret values', function () { $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); @@ -361,6 +361,6 @@ it('describes added unlocked environment variables with their value', function ( expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() ->and($change['old_display_value'])->toBe('-') - ->and($change['new_display_value'])->toBe('new-secret') - ->and(json_encode($diff->toArray()))->toContain('new-secret'); + ->and($change['new_display_value'])->toBe('••••••••') + ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); }); diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php deleted file mode 100644 index 48fb50c375..0000000000 --- a/tests/Unit/OauthSettingTest.php +++ /dev/null @@ -1,30 +0,0 @@ - 'oidc']); - expect($setting->couldBeEnabled())->toBeFalse(); - - $setting->fill([ - 'client_id' => 'client-id', - 'client_secret' => 'secret', - 'base_url' => 'https://idp.example.com', - ]); - - expect($setting->couldBeEnabled())->toBeTrue(); -}); - -it('returns configured scopes and custom login label', function () { - $setting = new OauthSetting([ - 'provider' => 'oidc', - 'scopes' => 'openid email profile groups', - 'custom_label' => 'Login with Okta', - ]); - - expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) - ->and($setting->loginLabel())->toBe('Login with Okta'); -}); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php deleted file mode 100644 index 18c358fd13..0000000000 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ /dev/null @@ -1,119 +0,0 @@ - Http::response([ - 'issuer' => 'https://idp.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]), - 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), - ]); - - $service = app(OidcDiscoveryService::class); - - $discovery = $service->discover('https://idp.example.com'); - $jwks = $service->jwks($discovery->jwksUri); - - expect($discovery->issuer)->toBe('https://idp.example.com') - ->and($jwks['keys'][0]['kid'])->toBe('one'); - - Http::assertSentCount(2); - - $service->discover('https://idp.example.com'); - $service->jwks('https://idp.example.com/jwks'); - - Http::assertSentCount(2); -}); - -it('does not cache discovery documents with mismatched issuers', function () { - Cache::flush(); - Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') - ->push([ - 'issuer' => 'https://evil.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]) - ->push([ - 'issuer' => 'https://idp.example.com', - 'authorization_endpoint' => 'https://idp.example.com/auth', - 'token_endpoint' => 'https://idp.example.com/token', - 'userinfo_endpoint' => 'https://idp.example.com/userinfo', - 'jwks_uri' => 'https://idp.example.com/jwks', - ]); - - $service = app(OidcDiscoveryService::class); - $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); - - expect(fn () => $service->discover('https://idp.example.com')) - ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') - ->and(Cache::has($cacheKey))->toBeFalse() - ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); - - Http::assertSentCount(2); -}); - -it('refetches jwks once on forced refresh to pick up rotated keys', function () { - Cache::flush(); - Http::fakeSequence('https://idp.example.com/jwks') - ->push(['keys' => [['kid' => 'old']]]) - ->push(['keys' => [['kid' => 'new']]]); - - $service = app(OidcDiscoveryService::class); - - expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); - - // Forced refresh bypasses the cache and sees the rotated key. - expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); - Http::assertSentCount(2); - - // Cooldown prevents a second immediate upstream fetch; cached value returned. - expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); - Http::assertSentCount(2); -}); - -it('rejects invalid discovery and jwks payloads', function () { - Cache::flush(); - Http::fake([ - 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), - ]); - - app(OidcDiscoveryService::class)->discover('https://bad.example.com'); -})->throws(OidcDiscoveryException::class); - -it('rejects jwks responses without keys', function () { - Cache::flush(); - Http::fake([ - 'https://idp.example.com/jwks' => Http::response(['empty' => true]), - ]); - - app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); -})->throws(OidcJwksException::class); - -it('rejects non-https issuer urls', function () { - Cache::flush(); - Http::fake(); - - app(OidcDiscoveryService::class)->discover('http://idp.example.com'); -})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); - -it('rejects non-https jwks uris', function () { - Cache::flush(); - Http::fake(); - - app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); -})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.'); diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php deleted file mode 100644 index b92ff58ffe..0000000000 --- a/tests/Unit/OidcProviderPkceTest.php +++ /dev/null @@ -1,148 +0,0 @@ -getAuthUrl($state); - } -} - -function oidc_provider_discovery_document(): OidcDiscoveryDocument -{ - return new OidcDiscoveryDocument( - issuer: 'https://idp.example.com', - authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', - tokenEndpoint: 'https://idp.example.com/oauth2/token', - userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', - jwksUri: 'https://idp.example.com/.well-known/jwks.json', - ); -} - -function oidc_provider_session(): Store -{ - $session = new Store('testing', new ArraySessionHandler(1200)); - $session->start(); - - return $session; -} - -function oidc_provider_request(Store $session, string $state = 'state-value'): Request -{ - $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]); - $request->setLaravelSession($session); - - return $request; -} - -function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl -{ - /** @var OidcDiscoveryService&MockInterface $discoveryService */ - $discoveryService = Mockery::mock(OidcDiscoveryService::class); - $discoveryService->shouldReceive('discover') - ->byDefault() - ->with('https://idp.example.com') - ->andReturn(oidc_provider_discovery_document()); - - /** @var OidcTokenValidator&MockInterface $tokenValidator */ - $tokenValidator = Mockery::mock(OidcTokenValidator::class); - - return (new TestOidcProviderWithExposedAuthUrl( - $request, - $discoveryService, - $tokenValidator, - 'client-id', - 'client-secret', - 'https://coolify.example.com/auth/oidc/callback', - ))->setConfig(new OidcConfig( - issuerUrl: 'https://idp.example.com', - clientId: 'client-id', - clientSecret: 'client-secret', - redirectUri: 'https://coolify.example.com/auth/oidc/callback', - usePkce: true, - )); -} - -it('stores oidc nonce and pkce verifier with a ten minute expiry', function () { - Carbon::setTestNow('2026-06-15 12:00:00'); - - try { - $session = oidc_provider_session(); - $provider = oidc_provider(oidc_provider_request($session)); - - $provider->authUrlForState('state-value'); - - $nonceEntry = $session->get('oidc.nonce.state-value'); - $verifierEntry = $session->get('oidc.code_verifier.state-value'); - - expect($nonceEntry)->toBeArray() - ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty() - ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp) - ->and($verifierEntry)->toBeArray() - ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty() - ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp); - } finally { - Carbon::setTestNow(); - } -}); - -it('sends a fresh oidc pkce verifier during token exchange', function () { - $session = oidc_provider_session(); - $session->put('oidc.code_verifier.state-value', [ - 'value' => 'fresh-verifier', - 'expires_at' => now()->addMinute()->timestamp, - ]); - - $provider = oidc_provider(oidc_provider_request($session)); - $history = []; - $handler = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), - ])); - $handler->push(Middleware::history($history)); - $provider->setHttpClient(new Client(['handler' => $handler])); - - $provider->getAccessTokenResponse('authorization-code'); - - parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); - - expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier') - ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); -}); - -it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () { - $session = oidc_provider_session(); - $session->put('oidc.code_verifier.state-value', [ - 'value' => 'expired-verifier', - 'expires_at' => now()->subSecond()->timestamp, - ]); - - $provider = oidc_provider(oidc_provider_request($session)); - $history = []; - $handler = HandlerStack::create(new MockHandler([ - new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), - ])); - $handler->push(Middleware::history($history)); - $provider->setHttpClient(new Client(['handler' => $handler])); - - $provider->getAccessTokenResponse('authorization-code'); -})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php deleted file mode 100644 index 9b1d9a24c3..0000000000 --- a/tests/Unit/OidcTokenValidatorTest.php +++ /dev/null @@ -1,187 +0,0 @@ - 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - openssl_pkey_export($privateKey, $privatePem); - $details = openssl_pkey_get_details($privateKey); - - return [ - 'private_pem' => $privatePem, - 'jwks' => [ - 'keys' => [[ - 'kty' => 'RSA', - 'kid' => $kid, - 'alg' => 'RS256', - 'use' => 'sig', - 'n' => oidc_base64url($details['rsa']['n']), - 'e' => oidc_base64url($details['rsa']['e']), - ]], - ], - ]; -} - -function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string -{ - $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); - $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR)); - $signatureInput = $header.'.'.$payload; - openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256); - - return $signatureInput.'.'.oidc_base64url($signature); -} - -function oidc_discovery(): OidcDiscoveryDocument -{ - return new OidcDiscoveryDocument( - issuer: 'https://idp.example.com', - authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', - tokenEndpoint: 'https://idp.example.com/oauth2/token', - userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', - jwksUri: 'https://idp.example.com/.well-known/jwks.json', - ); -} - -it('validates a well formed RS256 id token', function () { - $keyset = oidc_keyset(); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - 'email' => 'User@Example.com', - ], $keyset['private_pem']); - - $claims = app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); - - expect($claims['sub'])->toBe('okta-user-1') - ->and($claims['email'])->toBe('User@Example.com'); -}); - -it('rejects invalid token claims', function (array $claimOverrides, string $message) { - $keyset = oidc_keyset(); - $now = time(); - $claims = array_merge([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - ], $claimOverrides); - - $token = oidc_token($claims, $keyset['private_pem']); - - app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); -})->throws(OidcTokenException::class)->with([ - 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'], - 'audience mismatch' => [['aud' => 'other-client'], 'audience'], - 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'], - 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'], - 'expired token' => [['exp' => time() - 3600], 'expired'], - 'future issued at' => [['iat' => time() + 3600], 'issued'], - 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'], - 'missing subject' => [['sub' => null], 'subject'], - 'empty subject' => [['sub' => ''], 'subject'], - 'non-string subject' => [['sub' => 123], 'subject'], -]); - -it('rejects a bad signature and unknown key id', function (string $kid) { - $keyset = oidc_keyset('test-key'); - $otherKeyset = oidc_keyset($kid); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - 'nonce' => 'expected-nonce', - ], $otherKeyset['private_pem'], $kid); - - app(OidcTokenValidator::class)->validate( - idToken: $token, - discovery: oidc_discovery(), - jwks: $keyset['jwks'], - clientId: 'client-id', - expectedNonce: 'expected-nonce', - ); -})->throws(OidcTokenException::class)->with([ - 'same kid with bad signature' => ['test-key'], - 'unknown kid' => ['other-key'], -]); - -it('rejects disallowed algorithms', function () { - $keyset = oidc_keyset(); - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - ], $keyset['private_pem'], algorithm: 'HS256'); - - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcTokenException::class); - -it('throws a dedicated exception when the signing key is unknown', function () { - $keyset = oidc_keyset('current-key'); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => time(), - 'exp' => time() + 600, - ], $keyset['private_pem'], 'rotated-key'); - - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcSigningKeyNotFoundException::class); - -it('rejects a jwks key not designated for signing', function () { - $keyset = oidc_keyset(); - $keyset['jwks']['keys'][0]['use'] = 'enc'; - $now = time(); - $token = oidc_token([ - 'iss' => 'https://idp.example.com', - 'aud' => 'client-id', - 'sub' => 'okta-user-1', - 'iat' => $now, - 'exp' => $now + 600, - ], $keyset['private_pem']); - - // An encryption-only key is dropped from the keyset, so the kid no longer resolves. - app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); -})->throws(OidcTokenException::class); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index 4dedc7a768..d2d4ae600f 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,16 +23,6 @@ class SshMultiplexingDisableTest extends TestCase ); } - public function test_remote_shell_prefers_bash_and_falls_back_to_sh() - { - $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); - - $this->assertSame( - 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', - $reflection->invoke(null) - ); - } - public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand'); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 4a275ad484..7a73f59795 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -4,7 +4,6 @@ use App\Livewire\Project\Shared\Danger; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; -use App\Models\OauthIdentity; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -19,7 +18,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::forceCreate(['id' => 0]); + InstanceSettings::create(['id' => 0]); Queue::fake(); $this->user = User::factory()->create([ @@ -71,21 +70,6 @@ test('delete succeeds with correct password and redirects', function () { expect(Application::find($this->application->id))->toBeNull(); }); -test('delete succeeds without password for an oauth user', function () { - OauthIdentity::create([ - 'user_id' => $this->user->id, - 'provider' => 'oidc', - 'issuer' => 'https://idp.example.com', - 'provider_user_id' => 'oauth-user-id', - ]); - - Livewire::test(Danger::class, ['resource' => $this->application]) - ->call('delete', '') - ->assertHasNoErrors(); - - expect(Application::find($this->application->id))->toBeNull(); -}); - test('delete applies selectedActions from checkbox state', function () { $component = Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); From ca5fcce39b9590b2f582dee0e3df44b4e0b4d10c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:15:46 +0200 Subject: [PATCH 7/8] Reapply "Merge branch 'next' into main" This reverts commit 7bbd91175f3865018b17aa3273b189c511ce459f. --- .env.testing | 1 + app/Actions/Fortify/CreateNewUser.php | 2 +- app/Actions/Server/CheckUpdates.php | 40 +- app/Actions/Server/InstallDocker.php | 27 +- app/Actions/Server/InstallPrerequisites.php | 16 + app/Actions/Server/UpdatePackage.php | 4 + .../Exceptions/OidcDiscoveryException.php | 5 + app/Auth/Oidc/Exceptions/OidcException.php | 7 + .../Oidc/Exceptions/OidcJwksException.php | 5 + .../OidcSigningKeyNotFoundException.php | 5 + .../Oidc/Exceptions/OidcTokenException.php | 5 + app/Auth/Oidc/OidcConfig.php | 34 ++ app/Auth/Oidc/OidcDiscoveryDocument.php | 61 +++ app/Auth/Oidc/OidcDiscoveryService.php | 97 +++++ app/Auth/Oidc/OidcTokenValidator.php | 199 ++++++++++ app/Auth/Oidc/OidcUser.php | 32 ++ app/Auth/Oidc/Socialite/OidcProvider.php | 299 +++++++++++++++ app/Helpers/SshMultiplexingHelper.php | 8 +- app/Http/Controllers/OauthController.php | 61 +-- app/Livewire/Notifications/Discord.php | 24 ++ app/Livewire/Notifications/Email.php | 121 ++++-- app/Livewire/Notifications/Pushover.php | 28 ++ app/Livewire/Notifications/Slack.php | 26 ++ app/Livewire/Notifications/Telegram.php | 28 ++ app/Livewire/Notifications/Webhook.php | 24 ++ app/Livewire/Profile/Index.php | 59 ++- app/Livewire/Project/Service/Storage.php | 14 +- .../Shared/EnvironmentVariable/Show.php | 22 +- .../EnvironmentVariable/ShowHardcoded.php | 19 + app/Livewire/Project/Shared/Storages/All.php | 19 + .../Security/IntegrationTokenEditor.php | 114 ++++++ .../Security/IntegrationTokenForm.php | 81 ++++ app/Livewire/Security/IntegrationTokens.php | 41 +++ app/Livewire/Server/LogDrains.php | 72 ++++ app/Livewire/Settings/Advanced.php | 6 + app/Livewire/SettingsEmail.php | 124 +++++-- app/Livewire/SettingsOauth.php | 346 ++++++++++++------ app/Models/EnvironmentVariable.php | 17 + app/Models/InstanceSettings.php | 16 + app/Models/IntegrationToken.php | 38 ++ app/Models/OauthIdentity.php | 35 ++ app/Models/OauthSetting.php | 51 ++- app/Models/Team.php | 5 + app/Models/User.php | 17 +- app/Policies/IntegrationTokenPolicy.php | 34 ++ app/Providers/AppServiceProvider.php | 36 +- app/Providers/AuthServiceProvider.php | 3 + app/Providers/DuskServiceProvider.php | 21 -- app/Providers/FortifyServiceProvider.php | 8 +- app/Services/Auth/OauthLoginService.php | 228 ++++++++++++ app/Services/CloudflareTokenValidator.php | 42 +++ bootstrap/helpers/shared.php | 9 +- bootstrap/helpers/socialite.php | 28 +- composer.json | 2 +- composer.lock | 142 +------ config/app.php | 4 +- config/services.php | 8 + ...ation_deployment_configuration_columns.php | 6 + ...dd_oidc_fields_to_oauth_settings_table.php | 40 ++ ...4_091631_create_oauth_identities_table.php | 36 ++ ...tion_policy_to_instance_settings_table.php | 28 ++ ...join_root_team_to_oauth_settings_table.php | 28 ++ ...000000_create_integration_tokens_table.php | 29 ++ database/seeders/OauthSettingSeeder.php | 1 + database/seeders/UserSeeder.php | 2 - lang/de.json | 1 + lang/en.json | 1 + lang/pl.json | 1 + public/svgs/oidc.svg | 5 + resources/js/app.js | 2 + resources/js/copy-button.js | 35 ++ resources/views/auth/login.blade.php | 8 +- .../views/components/copy-button.blade.php | 32 +- .../components/forms/copy-button.blade.php | 28 -- .../components/forms/copy-input.blade.php | 15 + .../components/modal-confirmation.blade.php | 13 +- resources/views/components/reicon.blade.php | 1 + .../security/settings-layout.blade.php | 6 + .../components/settings/sidebar.blade.php | 18 + resources/views/layouts/base.blade.php | 24 -- .../views/livewire/profile/index.blade.php | 22 +- .../application/internal-access.blade.php | 8 +- .../project/service/storage.blade.php | 16 - .../shared/environment-variable/all.blade.php | 3 +- .../show-hardcoded.blade.php | 5 +- .../environment-variable/show.blade.php | 5 +- .../shared/partials/dns-copy-cell.blade.php | 41 +-- .../project/shared/resource-details.blade.php | 20 +- .../project/shared/storages/all.blade.php | 19 +- .../volume-backups/executions.blade.php | 2 +- .../project/shared/webhooks.blade.php | 6 +- .../livewire/security/api-tokens.blade.php | 7 +- .../integration-token-editor.blade.php | 52 +++ .../security/integration-token-form.blade.php | 49 +++ .../security/integration-tokens.blade.php | 84 +++++ .../server/ca-certificate/show.blade.php | 2 +- .../server/security/patches.blade.php | 4 +- .../views/livewire/settings-oauth.blade.php | 142 ++++--- .../livewire/settings/advanced.blade.php | 11 +- .../views/livewire/team/invitations.blade.php | 9 +- routes/web.php | 5 + templates/service-templates-latest.json | 4 +- templates/service-templates.json | 4 +- tests/Browser/LoginTest.php | 27 -- tests/Browser/Project/ProjectAddNewTest.php | 34 -- tests/Browser/Project/ProjectSearchTest.php | 29 -- tests/Browser/Project/ProjectTest.php | 27 -- tests/Browser/console/.gitignore | 2 - tests/Browser/source/.gitignore | 2 - tests/DuskTestCase.php | 57 --- .../EnvironmentVariableValueHidingTest.php | 18 +- tests/Feature/CopyButtonComponentTest.php | 38 +- tests/Feature/EnableActionButtonsTest.php | 179 +++++++++ .../EnvironmentVariableAsyncLoadTest.php | 2 +- .../EnvironmentVariableCopyValueTest.php | 151 ++++++++ .../LogDrain/LogDrainToggleRollbackTest.php | 45 +++ tests/Feature/LoginPageBrandingTest.php | 22 ++ tests/Feature/OauthControllerTest.php | 114 +++++- tests/Feature/OauthRegistrationPolicyTest.php | 52 +++ tests/Feature/OidcOauthControllerTest.php | 275 ++++++++++++++ .../PersistentStorageVolumesLayoutTest.php | 59 ++- tests/Feature/ProfileSsoIndicatorTest.php | 91 +++++ .../Feature/ResourceDetailsVisibilityTest.php | 16 +- .../Security/IntegrationTokenFormTest.php | 253 +++++++++++++ .../SecuritySettingsNavigationTest.php | 2 + .../SettingsEmailProviderExclusivityTest.php | 64 ++++ tests/Feature/SettingsNavigationTest.php | 52 +++ tests/Feature/SettingsOauthTest.php | 277 ++++++++++++++ tests/Feature/SshMultiplexingLockTest.php | 2 +- tests/Feature/TeamInvitationUiTest.php | 14 +- tests/Feature/UserSeederTest.php | 16 + .../Server/AlpinePackageManagerTest.php | 62 ++++ .../ApplicationConfigurationSnapshotTest.php | 14 +- tests/Unit/OauthSettingTest.php | 30 ++ tests/Unit/OidcDiscoveryServiceTest.php | 119 ++++++ tests/Unit/OidcProviderPkceTest.php | 148 ++++++++ tests/Unit/OidcTokenValidatorTest.php | 187 ++++++++++ tests/Unit/SshMultiplexingDisableTest.php | 10 + tests/v4/Feature/DangerDeleteResourceTest.php | 18 +- 139 files changed, 5343 insertions(+), 865 deletions(-) create mode 100644 app/Auth/Oidc/Exceptions/OidcDiscoveryException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcJwksException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php create mode 100644 app/Auth/Oidc/Exceptions/OidcTokenException.php create mode 100644 app/Auth/Oidc/OidcConfig.php create mode 100644 app/Auth/Oidc/OidcDiscoveryDocument.php create mode 100644 app/Auth/Oidc/OidcDiscoveryService.php create mode 100644 app/Auth/Oidc/OidcTokenValidator.php create mode 100644 app/Auth/Oidc/OidcUser.php create mode 100644 app/Auth/Oidc/Socialite/OidcProvider.php create mode 100644 app/Livewire/Security/IntegrationTokenEditor.php create mode 100644 app/Livewire/Security/IntegrationTokenForm.php create mode 100644 app/Livewire/Security/IntegrationTokens.php create mode 100644 app/Models/IntegrationToken.php create mode 100644 app/Models/OauthIdentity.php create mode 100644 app/Policies/IntegrationTokenPolicy.php delete mode 100644 app/Providers/DuskServiceProvider.php create mode 100644 app/Services/Auth/OauthLoginService.php create mode 100644 app/Services/CloudflareTokenValidator.php create mode 100644 database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php create mode 100644 database/migrations/2026_06_04_091631_create_oauth_identities_table.php create mode 100644 database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php create mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php create mode 100644 database/migrations/2026_08_15_000000_create_integration_tokens_table.php create mode 100644 public/svgs/oidc.svg create mode 100644 resources/js/copy-button.js delete mode 100644 resources/views/components/forms/copy-button.blade.php create mode 100644 resources/views/components/forms/copy-input.blade.php create mode 100644 resources/views/livewire/security/integration-token-editor.blade.php create mode 100644 resources/views/livewire/security/integration-token-form.blade.php create mode 100644 resources/views/livewire/security/integration-tokens.blade.php delete mode 100644 tests/Browser/LoginTest.php delete mode 100644 tests/Browser/Project/ProjectAddNewTest.php delete mode 100644 tests/Browser/Project/ProjectSearchTest.php delete mode 100644 tests/Browser/Project/ProjectTest.php delete mode 100644 tests/Browser/console/.gitignore delete mode 100644 tests/Browser/source/.gitignore delete mode 100644 tests/DuskTestCase.php create mode 100644 tests/Feature/EnableActionButtonsTest.php create mode 100644 tests/Feature/EnvironmentVariableCopyValueTest.php create mode 100644 tests/Feature/LogDrain/LogDrainToggleRollbackTest.php create mode 100644 tests/Feature/OauthRegistrationPolicyTest.php create mode 100644 tests/Feature/OidcOauthControllerTest.php create mode 100644 tests/Feature/ProfileSsoIndicatorTest.php create mode 100644 tests/Feature/Security/IntegrationTokenFormTest.php create mode 100644 tests/Feature/SettingsEmailProviderExclusivityTest.php create mode 100644 tests/Feature/SettingsNavigationTest.php create mode 100644 tests/Feature/SettingsOauthTest.php create mode 100644 tests/Feature/UserSeederTest.php create mode 100644 tests/Unit/Actions/Server/AlpinePackageManagerTest.php create mode 100644 tests/Unit/OauthSettingTest.php create mode 100644 tests/Unit/OidcDiscoveryServiceTest.php create mode 100644 tests/Unit/OidcProviderPkceTest.php create mode 100644 tests/Unit/OidcTokenValidatorTest.php diff --git a/.env.testing b/.env.testing index 1a73117986..d445b5afed 100644 --- a/.env.testing +++ b/.env.testing @@ -1,6 +1,7 @@ APP_ENV=testing APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k= APP_DEBUG=true +APP_MAINTENANCE_DRIVER=file DB_CONNECTION=testing diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 44a03c17da..d437a3a176 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { abort(403); } diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..5cf5658f8f 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -3,6 +3,7 @@ namespace App\Actions\Server; use App\Models\Server; +use Illuminate\Support\Facades\Log; use Lorisleiva\Actions\Concerns\AsAction; class CheckUpdates @@ -106,6 +107,15 @@ class CheckUpdates $out['osId'] = $osId; $out['package_manager'] = $packageManager; + return $out; + case 'apk': + instant_remote_process(['apk update -q'], $server); + $output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server); + + $out = $this->parseApkOutput($output); + $out['osId'] = $osId; + $out['package_manager'] = $packageManager; + return $out; default: return [ @@ -266,11 +276,39 @@ class CheckUpdates // Include unparsed lines in the result for debugging if any exist if (! empty($unparsedLines)) { $result['unparsed_lines'] = $unparsedLines; - \Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [ + Log::debug('Pacman output contained unparsed lines', [ 'unparsed_lines' => $unparsedLines, ]); } return $result; } + + private function parseApkOutput(string $output): array + { + $updates = []; + $lines = explode("\n", $output); + + foreach ($lines as $line) { + // Skip empty lines + if (empty($line)) { + continue; + } + + // Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] + if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) { + $updates[] = [ + 'package' => $matches[1], + 'new_version' => $matches[2], + 'architecture' => $matches[3], + 'current_version' => $matches[4], + ]; + } + } + + return [ + 'total_updates' => count($updates), + 'updates' => $updates, + ]; + } } diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..552445d728 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,6 +79,8 @@ class InstallDocker $command = $command->merge([$this->getSuseDockerInstallCommand()]); } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge([$this->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -93,9 +95,8 @@ class InstallDocker "jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null", 'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json', "echo 'Restarting Docker Engine...'", - 'systemctl enable docker >/dev/null 2>&1 || true', - 'systemctl restart docker', ]); + $command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine'))); if ($server->isSwarm()) { $command = $command->merge([ 'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true', @@ -154,6 +155,28 @@ class InstallDocker 'systemctl start docker.service'; } + private function getAlpineDockerInstallCommand(): string + { + return 'apk update && '. + 'apk add docker docker-cli-buildx docker-cli-compose && '. + 'mkdir -p /etc/docker'; + } + + private function getDockerServiceCommands(bool $usesOpenRc): array + { + if ($usesOpenRc) { + return [ + 'rc-update add docker default', + 'rc-service docker restart', + ]; + } + + return [ + 'systemctl enable docker >/dev/null 2>&1 || true', + 'systemctl restart docker', + ]; + } + private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..57fd4f1d7c 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,6 +53,8 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", 'pacman -Syu --noconfirm --needed curl wget git jq', ]); + } elseif ($supported_os_type->contains('alpine')) { + $command = $command->merge($this->getAlpinePrerequisiteCommands()); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } @@ -61,4 +63,18 @@ class InstallPrerequisites return remote_process($command, $server); } + + private function getAlpinePrerequisiteCommands(): array + { + return [ + "echo 'Installing Prerequisites for Alpine Linux...'", + "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", + 'apk update', + 'command -v bash >/dev/null || apk add bash', + 'command -v curl >/dev/null || apk add curl', + 'command -v wget >/dev/null || apk add wget', + 'command -v git >/dev/null || apk add git', + 'command -v jq >/dev/null || apk add jq', + ]; + } } diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..2b06e06011 100644 --- a/app/Actions/Server/UpdatePackage.php +++ b/app/Actions/Server/UpdatePackage.php @@ -58,6 +58,10 @@ class UpdatePackage $commandAll = 'pacman -Syu --noconfirm'; $commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage; break; + case 'apk': + $commandAll = 'apk update && apk upgrade'; + $commandInstall = 'apk upgrade '.$sanitizedPackage; + break; default: return [ 'error' => 'OS not supported', diff --git a/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php new file mode 100644 index 0000000000..e4a2ba0dfe --- /dev/null +++ b/app/Auth/Oidc/Exceptions/OidcDiscoveryException.php @@ -0,0 +1,5 @@ + $scopes + */ + public function __construct( + public string $issuerUrl, + public string $clientId, + public string $clientSecret, + public string $redirectUri, + public array $scopes = ['openid', 'email', 'profile'], + public bool $usePkce = true, + public int $clockSkewSeconds = 60, + ) {} + + public static function fromOauthSetting(OauthSetting $setting): self + { + return new self( + issuerUrl: rtrim((string) $setting->base_url, '/'), + clientId: (string) $setting->client_id, + clientSecret: (string) $setting->client_secret, + redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'), + scopes: $setting->scopeList(), + usePkce: $setting->use_pkce ?? true, + clockSkewSeconds: $setting->clock_skew_seconds ?? 60, + ); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryDocument.php b/app/Auth/Oidc/OidcDiscoveryDocument.php new file mode 100644 index 0000000000..d17061c51d --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryDocument.php @@ -0,0 +1,61 @@ + $supportedScopes + * @param array $supportedClaims + * @param array $idTokenSigningAlgValuesSupported + */ + public function __construct( + public string $issuer, + public string $authorizationEndpoint, + public string $tokenEndpoint, + public string $userinfoEndpoint, + public string $jwksUri, + public ?string $endSessionEndpoint = null, + public array $supportedScopes = [], + public array $supportedClaims = [], + public array $idTokenSigningAlgValuesSupported = [], + ) {} + + /** + * @param array $payload + */ + public static function fromArray(array $payload): self + { + foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) { + if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') { + throw new OidcDiscoveryException("Discovery document is missing required field: {$field}"); + } + } + + return new self( + issuer: $payload['issuer'], + authorizationEndpoint: $payload['authorization_endpoint'], + tokenEndpoint: $payload['token_endpoint'], + userinfoEndpoint: $payload['userinfo_endpoint'], + jwksUri: $payload['jwks_uri'], + endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null, + supportedScopes: self::stringList($payload['scopes_supported'] ?? []), + supportedClaims: self::stringList($payload['claims_supported'] ?? []), + idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []), + ); + } + + /** + * @return array + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_map('strval', $value)); + } +} diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php new file mode 100644 index 0000000000..0847afc9a7 --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -0,0 +1,97 @@ +assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.')); + + $issuerUrl = rtrim($issuerUrl, '/'); + $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); + + return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument { + $url = $issuerUrl.'/.well-known/openid-configuration'; + + try { + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url); + } catch (Throwable $e) { + throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e); + } + + if ($response->failed()) { + throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}"); + } + + $json = $response->json(); + if (! is_array($json) || $json === []) { + throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.'); + } + + $discovery = OidcDiscoveryDocument::fromArray($json); + if (rtrim($discovery->issuer, '/') !== $issuerUrl) { + throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); + } + + return $discovery; + }); + } + + /** + * Fetch the JWKS for the given URI. + * + * When $forceRefresh is true the cached document is bypassed so freshly + * rotated signing keys become visible immediately. A short cooldown still + * prevents a flood of upstream requests if many logins miss the same kid. + * + * @return array + */ + public function jwks(string $jwksUri, bool $forceRefresh = false): array + { + $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.')); + + $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); + + if ($forceRefresh) { + $cooldownKey = $cacheKey.':refresh'; + if (Cache::add($cooldownKey, true, 60)) { + Cache::forget($cacheKey); + } + } + + return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { + try { + $response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri); + } catch (Throwable $e) { + throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e); + } + + if ($response->failed()) { + throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}"); + } + + $json = $response->json(); + if (! is_array($json) || ! is_array($json['keys'] ?? null)) { + throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'."); + } + + return $json; + }); + } + + private function assertHttpsUrl(string $url, Throwable $exception): void + { + $parts = parse_url($url); + + if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') { + throw $exception; + } + } +} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php new file mode 100644 index 0000000000..a8563611dd --- /dev/null +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -0,0 +1,199 @@ + $jwks + * @return array + */ + public function validate( + string $idToken, + OidcDiscoveryDocument $discovery, + array $jwks, + string $clientId, + ?string $expectedNonce = null, + int $clockSkewSeconds = 60, + ): array { + $kid = $this->extractKid($idToken); + + try { + $keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM); + } catch (Throwable $e) { + throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e); + } + + // Surface an unknown signing key distinctly so the caller can refresh + // the JWKS once (key rotation) before giving up. + if (! array_key_exists($kid, $keys)) { + throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); + } + + $previousLeeway = JWT::$leeway; + JWT::$leeway = $clockSkewSeconds; + + try { + // Validates signature, header alg against the key alg (RS256), + // exp, nbf and iat. Throws on any failure. + $claims = (array) JWT::decode($idToken, $keys); + } catch (OidcTokenException $e) { + throw $e; + } catch (Throwable $e) { + throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e); + } finally { + JWT::$leeway = $previousLeeway; + } + + $this->assertExpiry($claims); + $this->assertIssuer($claims, $discovery->issuer); + $this->assertAudience($claims, $clientId); + $this->assertNonce($claims, $expectedNonce); + $this->assertSubject($claims); + + return $claims; + } + + /** + * Drop JWKS entries explicitly marked for anything other than signing + * (e.g. "use":"enc") so they can never verify an id_token signature. + * firebase/php-jwt does not honour the "use" parameter on its own. + * + * @param array $jwks + * @return array + */ + private function signingKeysOnly(array $jwks): array + { + $keys = array_values(array_filter( + $jwks['keys'] ?? [], + fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'), + )); + + return ['keys' => $keys]; + } + + /** + * Decode just the JWT header to read the kid before signature + * verification, so an unknown key can be reported as a rotation miss. + */ + private function extractKid(string $idToken): string + { + $segments = explode('.', $idToken); + if (count($segments) !== 3) { + throw new OidcTokenException('Malformed id_token.'); + } + + $header = json_decode($this->base64UrlDecode($segments[0]), true); + if (! is_array($header)) { + throw new OidcTokenException('id_token header contains invalid JSON.'); + } + + if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) { + throw new OidcTokenException('id_token uses a disallowed algorithm.'); + } + + $kid = $header['kid'] ?? null; + if (! is_string($kid) || $kid === '') { + throw new OidcTokenException('id_token header is missing kid.'); + } + + return $kid; + } + + private function base64UrlDecode(string $value): string + { + $remainder = strlen($value) % 4; + if ($remainder !== 0) { + $value .= str_repeat('=', 4 - $remainder); + } + + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + if ($decoded === false) { + throw new OidcTokenException('Invalid base64url value in id_token header.'); + } + + return $decoded; + } + + /** + * @param array $claims + */ + private function assertExpiry(array $claims): void + { + // Firebase enforces the exp window when present; OIDC requires it to exist. + if (! is_numeric($claims['exp'] ?? null)) { + throw new OidcTokenException('id_token is missing the exp claim.'); + } + } + + /** + * @param array $claims + */ + private function assertSubject(array $claims): void + { + $subject = $claims['sub'] ?? null; + if (! is_string($subject) || $subject === '') { + throw new OidcTokenException('id_token subject is missing or invalid.'); + } + } + + /** + * @param array $claims + */ + private function assertIssuer(array $claims, string $expectedIssuer): void + { + if (($claims['iss'] ?? null) !== $expectedIssuer) { + throw new OidcTokenException('id_token issuer does not match discovery issuer.'); + } + } + + /** + * @param array $claims + */ + private function assertAudience(array $claims, string $clientId): void + { + $audience = $claims['aud'] ?? null; + if (is_string($audience)) { + $audience = [$audience]; + } + + if (! is_array($audience) || ! in_array($clientId, $audience, true)) { + throw new OidcTokenException('id_token audience does not include configured client id.'); + } + + if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) { + throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.'); + } + + if (isset($claims['azp']) && $claims['azp'] !== $clientId) { + throw new OidcTokenException('id_token azp does not match configured client id.'); + } + } + + /** + * @param array $claims + */ + private function assertNonce(array $claims, ?string $expectedNonce): void + { + if ($expectedNonce === null) { + return; + } + + if (($claims['nonce'] ?? null) !== $expectedNonce) { + throw new OidcTokenException('id_token nonce does not match.'); + } + } +} diff --git a/app/Auth/Oidc/OidcUser.php b/app/Auth/Oidc/OidcUser.php new file mode 100644 index 0000000000..645130e019 --- /dev/null +++ b/app/Auth/Oidc/OidcUser.php @@ -0,0 +1,32 @@ + + */ + public array $idTokenClaims = []; + + /** + * @param array $claims + */ + public function setIdTokenClaims(array $claims): self + { + $this->idTokenClaims = $claims; + $this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null; + $this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null; + $this->emailVerified = ($claims['email_verified'] ?? false) === true; + + return $this; + } +} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php new file mode 100644 index 0000000000..383b0cc910 --- /dev/null +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -0,0 +1,299 @@ + + */ + protected $scopes = ['openid', 'email', 'profile']; + + protected $scopeSeparator = ' '; + + protected ?OidcConfig $oidcConfig = null; + + protected ?OidcDiscoveryDocument $discovery = null; + + public function __construct( + Request $request, + protected OidcDiscoveryService $discoveryService, + protected OidcTokenValidator $tokenValidator, + string $clientId, + string $clientSecret, + string $redirectUrl, + ) { + parent::__construct($request, $clientId, $clientSecret, $redirectUrl); + } + + public function setConfig(OidcConfig $config): self + { + $this->oidcConfig = $config; + $this->clientId = $config->clientId; + $this->clientSecret = $config->clientSecret; + $this->redirectUrl = $config->redirectUri; + $this->scopes = $config->scopes; + $this->discovery = null; + + return $this; + } + + public function getConfig(): OidcConfig + { + if ($this->oidcConfig === null) { + throw new OidcException('OIDC provider config is not set.'); + } + + return $this->oidcConfig; + } + + protected function getAuthUrl($state): string + { + $config = $this->getConfig(); + $nonce = Str::random(40); + $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce); + + $extra = ['nonce' => $nonce]; + if ($config->usePkce) { + $verifier = $this->generateCodeVerifier(); + $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier); + $extra['code_challenge'] = $this->codeChallenge($verifier); + $extra['code_challenge_method'] = 'S256'; + } + + return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state) + .'&'.http_build_query($extra, '', '&', $this->encodingType); + } + + protected function getTokenUrl(): string + { + return $this->resolveDiscovery()->tokenEndpoint; + } + + /** + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$token, + ], + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, + ]); + + $decoded = json_decode((string) $response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user) + { + return (new OidcUser)->setRaw($user)->map([ + 'id' => $user['sub'] ?? null, + 'nickname' => $user['preferred_username'] ?? null, + 'name' => $this->resolveName($user), + 'email' => $user['email'] ?? null, + 'avatar' => $user['picture'] ?? null, + ]); + } + + public function user() + { + if ($this->user) { + return $this->user; + } + + if ($this->hasInvalidState()) { + throw new InvalidStateException; + } + + $tokenResponse = $this->getAccessTokenResponse($this->getCode()); + $accessToken = Arr::get($tokenResponse, 'access_token'); + $idToken = Arr::get($tokenResponse, 'id_token'); + + if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') { + throw new OidcException('OIDC token endpoint did not return required tokens.'); + } + + $discovery = $this->resolveDiscovery(); + $config = $this->getConfig(); + $expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state'))); + if ($expectedNonce === null) { + throw new OidcException('OIDC login session expired. Please try again.'); + } + + $claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce); + + $userinfo = $this->getUserByToken($accessToken); + + // OIDC core §5.3.2: the userinfo sub MUST match the id_token sub. + // Reject the response rather than trust unsigned userinfo claims. + $userinfoSub = $userinfo['sub'] ?? null; + if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) { + throw new OidcException('OIDC userinfo subject does not match the id_token subject.'); + } + + $merged = array_merge($userinfo, $claims); + + /** @var OidcUser $user */ + $user = $this->mapUserToObject($merged); + $user->setIdTokenClaims($claims) + ->setToken($accessToken) + ->setRefreshToken(Arr::get($tokenResponse, 'refresh_token')) + ->setExpiresIn(Arr::get($tokenResponse, 'expires_in')); + + return $this->user = $user; + } + + /** + * Validate the id_token, retrying once against a freshly fetched JWKS when + * the signing key is unknown. This keeps logins working immediately after + * the IdP rotates keys instead of failing until the JWKS cache expires. + * + * @return array + */ + protected function validateIdToken( + string $idToken, + OidcDiscoveryDocument $discovery, + OidcConfig $config, + ?string $expectedNonce, + ): array { + foreach ([false, true] as $forceRefresh) { + try { + return $this->tokenValidator->validate( + idToken: $idToken, + discovery: $discovery, + jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh), + clientId: $config->clientId, + expectedNonce: $expectedNonce, + clockSkewSeconds: $config->clockSkewSeconds, + ); + } catch (OidcSigningKeyNotFoundException $e) { + if ($forceRefresh) { + throw $e; + } + } + } + + throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.'); + } + + /** + * @return array + */ + public function getAccessTokenResponse($code) + { + $fields = $this->getTokenFields($code); + if ($this->getConfig()->usePkce) { + $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); + if ($verifier === null) { + throw new OidcException('OIDC login session expired. Please try again.'); + } + + $fields['code_verifier'] = $verifier; + } + + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + RequestOptions::FORM_PARAMS => $fields, + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, + ]); + + $decoded = json_decode((string) $response->getBody(), true); + + return is_array($decoded) ? $decoded : []; + } + + protected function resolveDiscovery(): OidcDiscoveryDocument + { + return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl); + } + + protected function generateCodeVerifier(): string + { + return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '='); + } + + protected function codeChallenge(string $verifier): string + { + return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + } + + /** + * @param array $user + */ + protected function resolveName(array $user): ?string + { + if (is_string($user['name'] ?? null) && $user['name'] !== '') { + return $user['name']; + } + + $name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? ''))); + + return $name === '' ? null : $name; + } + + protected function putOidcFlowValue(string $key, string $value): void + { + $this->request->session()->put($key, [ + 'value' => $value, + 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp, + ]); + } + + protected function pullOidcFlowValue(string $key): ?string + { + $entry = $this->request->session()->pull($key); + + if (! is_array($entry)) { + return null; + } + + $value = $entry['value'] ?? null; + $expiresAt = $entry['expires_at'] ?? null; + + if (! is_string($value) || $value === '' || ! is_int($expiresAt)) { + return null; + } + + if ($expiresAt < now()->timestamp) { + return null; + } + + return $value; + } + + protected function nonceSessionKey(string $state): string + { + return "oidc.nonce.{$state}"; + } + + protected function verifierSessionKey(string $state): string + { + return "oidc.code_verifier.{$state}"; + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index cbb18945e2..e7d6d071b4 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -243,12 +243,18 @@ class SshMultiplexingHelper $delimiter = base64_encode(Hash::make($command)); $command = str_replace($delimiter, '', $command); + $remoteShellCommand = self::remoteShellCommand(); - return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL + return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL .$command.PHP_EOL .$delimiter; } + private static function remoteShellCommand(): string + { + return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi'; + } + public static function getConnectionTimeout(Server $server): int { $timeout = data_get($server, 'settings.connection_timeout'); diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 4038fe63e2..93d27615a7 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -2,47 +2,60 @@ namespace App\Http\Controllers; -use App\Models\User; -use Illuminate\Support\Facades\Auth; +use App\Models\OauthSetting; +use App\Services\Auth\OauthLoginService; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\HttpException; class OauthController extends Controller { public function redirect(string $provider) { - $socialite_provider = get_socialite_provider($provider); + $oauthSetting = $this->enabledProvider($provider); + $socialiteProvider = get_socialite_provider($oauthSetting->provider); - return $socialite_provider->redirect(); + return $socialiteProvider->redirect(); } - public function callback(string $provider) + public function callback(string $provider, OauthLoginService $oauthLoginService) { try { - $oauthUser = get_socialite_provider($provider)->user(); - $email = trim((string) $oauthUser->email); - if ($email === '') { - abort(403, 'OAuth provider did not return an email address'); - } - $email = strtolower($email); - $user = User::whereEmail($email)->first(); - if (! $user) { - $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { - abort(403, 'Registration is disabled'); - } - - $user = User::create([ - 'name' => $oauthUser->name, - 'email' => $email, - ]); - } - Auth::login($user); + $oauthSetting = $this->enabledProvider($provider); + $oauthUser = get_socialite_provider($oauthSetting->provider)->user(); + $oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting); return redirect('/'); } catch (\Exception $e) { + $this->logCallbackFailure($provider, $e); + $errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback'; return redirect()->route('login')->withErrors([__($errorCode)]); } } + + private function logCallbackFailure(string $provider, \Throwable $exception): void + { + Log::error('OAuth callback failed.', [ + 'provider' => $provider, + 'exception_class' => $exception::class, + 'exception_message' => $exception->getMessage(), + 'request_error' => request()->query('error'), + 'request_error_description' => request()->query('error_description'), + 'has_code' => request()->query->has('code'), + 'has_state' => request()->query->has('state'), + 'ip' => request()->ip(), + 'exception' => $exception, + ]); + } + + private function enabledProvider(string $provider): OauthSetting + { + $oauthSetting = OauthSetting::where('provider', $provider)->first(); + if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) { + throw new HttpException(403, 'OAuth provider is not enabled'); + } + + return $oauthSetting; + } } diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 797db83629..59ecb06e8e 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -166,6 +166,30 @@ class Discord extends Component } } + public function toggleDiscordEnabled(): void + { + try { + $this->resetErrorBag(); + + if ($this->discordEnabled) { + $this->discordEnabled = false; + } else { + $this->validate([ + 'discordWebhookUrl' => 'required', + ], [ + 'discordWebhookUrl.required' => 'Discord Webhook URL is required.', + ]); + $this->discordEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 3d95668b91..2a373a5065 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -2,7 +2,6 @@ namespace App\Livewire\Notifications; -use App\Livewire\Notifications\Concerns\TogglesNotificationEvents; use App\Models\EmailNotificationSettings; use App\Models\Team; use App\Notifications\Test; @@ -15,7 +14,7 @@ use Livewire\Component; class Email extends Component { - use AuthorizesRequests, TogglesNotificationEvents; + use AuthorizesRequests; protected $listeners = ['refresh' => '$refresh']; @@ -252,32 +251,59 @@ class Email extends Component } } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->saveModel(); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->saveModel(); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function submitSmtp() { $this->authorize('update', $this->settings); try { $this->resetErrorBag(); - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); + $this->validateSmtpSettings(); if ($this->smtpEnabled) { $this->settings->resend_enabled = $this->resendEnabled = false; @@ -309,17 +335,7 @@ class Email extends Component try { $this->resetErrorBag(); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); + $this->validateResendSettings(); if ($this->resendEnabled) { $this->settings->smtp_enabled = $this->smtpEnabled = false; } @@ -336,6 +352,45 @@ class Email extends Component } } + private function validateSmtpSettings(): void + { + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); + } + + private function validateResendSettings(): void + { + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); + } + public function sendTestEmail() { try { diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index 3b7c3c6aeb..b1608c5ea2 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -159,6 +159,34 @@ class Pushover extends Component } } + public function togglePushoverEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->pushoverEnabled) { + $this->pushoverEnabled = false; + } else { + $this->validate([ + 'pushoverUserKey' => 'required', + 'pushoverApiToken' => 'required', + ], [ + 'pushoverUserKey.required' => 'Pushover User Key is required.', + 'pushoverApiToken.required' => 'Pushover API Token is required.', + ]); + $this->pushoverEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index 9ee3624025..c4ca7da802 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -150,6 +150,32 @@ class Slack extends Component } } + public function toggleSlackEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->slackEnabled) { + $this->slackEnabled = false; + } else { + $this->validate([ + 'slackWebhookUrl' => 'required', + ], [ + 'slackWebhookUrl.required' => 'Slack Webhook URL is required.', + ]); + $this->slackEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index b04d2c73d2..9f19b22f5f 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -252,6 +252,34 @@ class Telegram extends Component } } + public function toggleTelegramEnabled(): void + { + try { + $this->resetErrorBag(); + + if ($this->telegramEnabled) { + $this->telegramEnabled = false; + } else { + $this->validate([ + 'telegramToken' => 'required', + 'telegramChatId' => 'required', + ], [ + 'telegramToken.required' => 'Telegram Token is required.', + 'telegramChatId.required' => 'Telegram Chat ID is required.', + ]); + $this->telegramEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + handleError($e, $this); + } finally { + $this->dispatch('refresh'); + } + } + public function saveModel() { $this->syncData(true); diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index fcf1107781..ee07694767 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -144,6 +144,30 @@ class Webhook extends Component } } + public function toggleWebhookEnabled() + { + try { + $this->resetErrorBag(); + + if ($this->webhookEnabled) { + $this->webhookEnabled = false; + } else { + $this->validate([ + 'webhookUrl' => 'required', + ], [ + 'webhookUrl.required' => 'Webhook URL is required.', + ]); + $this->webhookEnabled = true; + } + + $this->saveModel(); + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index a20a1231b4..ae5d9b3ecd 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,19 +2,15 @@ namespace App\Livewire\Profile; -use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Validation\Rules\Password; use Livewire\Attributes\Validate; use Livewire\Component; -use Livewire\WithFileUploads; class Index extends Component { - use WithFileUploads; - public int $userId; public string $email; @@ -36,6 +32,10 @@ class Index extends Component public bool $show_verification = false; + public bool $uses_sso = false; + + public ?string $sso_provider_label = null; + public $avatar; public function uploadAvatar(AvatarStorageService $avatarStorage): bool @@ -75,8 +75,12 @@ class Index extends Component $this->name = Auth::user()->name; $this->email = Auth::user()->email; + $oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first(); + $this->uses_sso = $oauthIdentity !== null; + $this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null; + // Check if there's a pending email change - if (Auth::user()->hasEmailChangeRequest()) { + if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) { $this->new_email = Auth::user()->pending_email; $this->show_verification = true; } @@ -101,6 +105,10 @@ class Index extends Component public function requestEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // For self-hosted, check if email is enabled if (! isCloud()) { $settings = instanceSettings(); @@ -159,6 +167,10 @@ class Index extends Component public function verifyEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -204,7 +216,6 @@ class Index extends Component $this->show_verification = false; $this->dispatch('success', 'Email address updated successfully.'); - $this->dispatch('close-email-change-modal'); } else { $this->dispatch('error', 'Failed to update email address.'); } @@ -216,6 +227,10 @@ class Index extends Component public function resendVerificationCode() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // Check if there's a pending request if (! Auth::user()->hasEmailChangeRequest()) { $this->dispatch('error', 'No pending email change request.'); @@ -269,6 +284,30 @@ class Index extends Component $this->dispatch('success', 'Email change request cancelled.'); } + public function showEmailChangeForm() + { + if ($this->rejectSsoEmailChange()) { + return; + } + + $this->show_email_change = true; + $this->new_email = ''; + } + + private function rejectSsoEmailChange(): bool + { + if (! Auth::user()->hasSsoIdentity()) { + return false; + } + + $this->uses_sso = true; + $this->show_email_change = false; + $this->show_verification = false; + $this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.'); + + return true; + } + public function resetPassword() { try { @@ -299,6 +338,14 @@ class Index extends Component } } + private function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OIDC', + default => str($provider)->headline()->toString(), + }; + } + public function render() { return view('livewire.profile.index'); diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index ce278522b6..6880b5ab09 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -77,6 +77,7 @@ class Storage extends Component $this->activeTab = $this->resolveDefaultTab(); $this->fileStorage = collect(); $this->loadFileStorageForActiveTab(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -201,9 +202,7 @@ class Storage extends Component $this->validate([ 'name' => ValidationPatterns::volumeNameRules(), 'mount_path' => 'required|string', - 'host_path' => $this->isSwarm - ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN] - : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], + 'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN], ], array_merge(ValidationPatterns::volumeNameMessages(), [ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.', ])); @@ -340,7 +339,7 @@ class Storage extends Component public function clearForm() { - $this->name = ''; + $this->name = $this->generateDefaultVolumeName(); $this->mount_path = ''; $this->host_path = null; $this->file_storage_path = ''; @@ -373,6 +372,13 @@ class Storage extends Component throw new \Exception('No valid resource type for file mount storage type!'); } + private function generateDefaultVolumeName(): string + { + $name = str($this->resource->name)->slug()->value(); + + return ($name ?: 'volume').'-data'; + } + public function fileStoragePreviewPath(): string { $path = str($this->file_storage_path)->trim(); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 7f37b1fc4d..db80cff801 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -161,6 +161,22 @@ class Show extends Component $this->valuesLoaded = true; } + public function copyValue(): ?string + { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { + return null; + } + + if (! $this->env instanceof ModelsEnvironmentVariable) { + return $this->env->value; + } + + return $this->env->get_real_environment_variables_with_server( + $this->env->resolveReferencedValue(), + $this->env->resourceable, + ); + } + public function syncData(bool $toModel = false) { if ($toModel) { @@ -204,7 +220,7 @@ class Show extends Component $this->is_required = (bool) ($this->env->is_required ?? false); // Use the stored column, not the value-based accessor (that decrypts). $this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false); - $this->isValueHidden = auth()->user()?->isMember() ?? false; + $this->isValueHidden = auth()->user()?->isMember() ?? true; if ($this->valuesLoaded) { $this->hydrateValueFields(); @@ -231,12 +247,12 @@ class Show extends Component $this->is_really_required = $this->is_required && blank($this->value); } - if ($this->env->is_shown_once || auth()->user()?->isMember()) { + if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) { $this->value = null; $this->real_value = null; } - $this->isValueHidden = auth()->user()?->isMember() ?? false; + $this->isValueHidden = auth()->user()?->isMember() ?? true; } public function checkEnvs() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index da55dee197..c2f0059399 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared\EnvironmentVariable; +use App\Models\EnvironmentVariable; use Livewire\Component; class ShowHardcoded extends Component @@ -20,6 +21,10 @@ class ShowHardcoded extends Component public bool $isPreview = false; + public ?string $resourceableType = null; + + public ?int $resourceableId = null; + public function mount() { $this->key = $this->env['key']; @@ -28,6 +33,20 @@ class ShowHardcoded extends Component $this->serviceName = $this->env['service_name'] ?? null; } + public function copyValue(): ?string + { + if (auth()->user()?->isMember() ?? true) { + return null; + } + + return EnvironmentVariable::make([ + 'value' => $this->value, + 'is_preview' => $this->isPreview, + 'resourceable_type' => $this->resourceableType, + 'resourceable_id' => $this->resourceableId, + ])->resolveReferencedValue(); + } + public function render() { return view('livewire.project.shared.environment-variable.show-hardcoded'); diff --git a/app/Livewire/Project/Shared/Storages/All.php b/app/Livewire/Project/Shared/Storages/All.php index 583c2788a4..efe54a6a7d 100644 --- a/app/Livewire/Project/Shared/Storages/All.php +++ b/app/Livewire/Project/Shared/Storages/All.php @@ -107,6 +107,25 @@ class All extends Component $this->submit($storageId); } + public function clearHostPath(int $storageId): void + { + $this->authorize('update', $this->resource); + + $storage = $this->findStorageOrFail($storageId); + if ($storage->shouldBeReadOnlyInUI()) { + $this->dispatch('error', 'This volume is read-only.'); + + return; + } + + $storage->host_path = null; + $storage->save(); + $this->forms[$storageId]['hostPath'] = null; + + $this->dispatch('configurationChanged'); + $this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.'); + } + /** * Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms. */ diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php new file mode 100644 index 0000000000..453a7e8ae8 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -0,0 +1,114 @@ +integrationToken = IntegrationToken::ownedByCurrentTeam() + ->whereUuid($integration_token_uuid) + ->firstOrFail(); + + $this->authorize('view', $this->integrationToken); + + $this->name = $this->integrationToken->name; + $this->capabilities = $this->integrationToken->capabilities; + } + + protected function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'newToken' => ['nullable', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function save(CloudflareTokenValidator $validator): void + { + $this->authorize('update', $this->integrationToken); + $validated = $this->validate(); + $token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token; + $capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all() + !== collect($this->integrationToken->capabilities)->sort()->values()->all(); + + try { + if ((filled($validated['newToken']) || $capabilitiesChanged) + && ! $validator->validate($token, $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + $updates = [ + 'name' => $validated['name'], + 'capabilities' => $validated['capabilities'], + ]; + + if (filled($validated['newToken'])) { + $updates['token'] = $validated['newToken']; + } + + $this->integrationToken->update($updates); + $this->newToken = ''; + + auditLog('ui.integration_token.updated', [ + 'team_id' => currentTeam()->id, + 'integration_token_uuid' => $this->integrationToken->uuid, + 'integration_token_name' => $this->integrationToken->name, + 'provider' => $this->integrationToken->provider, + 'rotated' => array_key_exists('token', $updates), + ]); + + $this->dispatch( + 'integration-token-updated', + uuid: $this->integrationToken->uuid, + name: $this->integrationToken->name, + capabilities: $this->integrationToken->capabilities, + ); + $this->dispatch('success', 'Integration token updated successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function delete(string $password = ''): void + { + $this->authorize('delete', $this->integrationToken); + $this->integrationToken->delete(); + + $this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid); + $this->dispatch('close-modal'); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-token-editor'); + } +} diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php new file mode 100644 index 0000000000..7a7637bf5e --- /dev/null +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -0,0 +1,81 @@ +authorize('create', IntegrationToken::class); + } + + protected function rules(): array + { + return [ + 'provider' => ['required', 'in:cloudflare'], + 'name' => ['required', 'string', 'max:255'], + 'token' => ['required', 'string'], + 'capabilities' => ['required', 'array', 'min:1'], + 'capabilities.*' => ['required', 'in:dns'], + ]; + } + + protected function messages(): array + { + return [ + 'capabilities.required' => 'Select at least one capability.', + 'capabilities.min' => 'Select at least one capability.', + ]; + } + + public function addToken(CloudflareTokenValidator $validator): void + { + $validated = $this->validate(); + + try { + if (! $validator->validate($validated['token'], $validated['capabilities'])) { + $this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.'); + + return; + } + + IntegrationToken::query()->create([ + ...$validated, + 'team_id' => currentTeam()->id, + ]); + + $this->reset(['name', 'token']); + $this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class); + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } + + $this->dispatch('success', 'Integration token added successfully.'); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.security.integration-token-form'); + } +} diff --git a/app/Livewire/Security/IntegrationTokens.php b/app/Livewire/Security/IntegrationTokens.php new file mode 100644 index 0000000000..39db135b38 --- /dev/null +++ b/app/Livewire/Security/IntegrationTokens.php @@ -0,0 +1,41 @@ +authorize('viewAny', IntegrationToken::class); + $this->loadTokens(); + } + + #[On('integrationTokenAdded')] + public function loadTokens(): void + { + $this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get(); + } + + public function deleteToken(int $tokenId, string $password = ''): void + { + $token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId); + $this->authorize('delete', $token); + $token->delete(); + $this->loadTokens(); + $this->dispatch('success', 'Integration token deleted successfully.'); + } + + public function render() + { + return view('livewire.security.integration-tokens'); + } +} diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 3af0a22610..ae53488bd5 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,6 +177,49 @@ class LogDrains extends Component } } + public function toggleLogDrain(string $type): void + { + $previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled; + $previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled; + $previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled; + + try { + $this->authorize('update', $this->server); + $this->resetErrorBag(); + + $enabledProperty = $this->enabledProperty($type); + + if ($this->{$enabledProperty}) { + $this->{$enabledProperty} = false; + } else { + $this->validateLogDrainSettings($type); + $this->isLogDrainNewRelicEnabled = $type === 'newrelic'; + $this->isLogDrainAxiomEnabled = $type === 'axiom'; + $this->isLogDrainCustomEnabled = $type === 'custom'; + } + + $this->syncData(true); + + if ($this->server->isLogDrainEnabled()) { + StartLogDrain::run($this->server); + $this->dispatch('success', 'Log drain service started.'); + } else { + StopLogDrain::run($this->server); + $this->dispatch('success', 'Log drain service stopped.'); + } + } catch (\Throwable $e) { + // Restore the previously persisted enabled flags so the UI/DB never + // claim a runtime state that the Start/StopLogDrain action failed to apply. + $this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled; + $this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled; + $this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled; + $this->server->settings->save(); + $this->syncData(); + + handleError($e, $this); + } + } + public function submit() { try { @@ -192,4 +235,33 @@ class LogDrains extends Component { return view('livewire.server.log-drains'); } + + private function enabledProperty(string $type): string + { + return match ($type) { + 'newrelic' => 'isLogDrainNewRelicEnabled', + 'axiom' => 'isLogDrainAxiomEnabled', + 'custom' => 'isLogDrainCustomEnabled', + default => throw new \InvalidArgumentException('Unknown log drain type.'), + }; + } + + private function validateLogDrainSettings(string $type): void + { + match ($type) { + 'newrelic' => $this->validate([ + 'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainNewRelicBaseUri' => ['required', 'url'], + ]), + 'axiom' => $this->validate([ + 'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + 'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'], + ]), + 'custom' => $this->validate([ + 'logDrainCustomConfig' => ['required'], + 'logDrainCustomConfigParser' => ['string', 'nullable'], + ]), + default => throw new \InvalidArgumentException('Unknown log drain type.'), + }; + } } diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php index fd5ee616d9..38a2f85a73 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -19,6 +19,9 @@ class Advanced extends Component #[Validate('boolean')] public bool $is_registration_enabled; + #[Validate('boolean')] + public bool $disable_registration_when_oauth_enabled; + #[Validate('boolean')] public bool $do_not_track; @@ -59,6 +62,7 @@ class Advanced extends Component { return [ 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'do_not_track' => 'boolean', 'is_dns_validation_enabled' => 'boolean', 'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers], @@ -84,6 +88,7 @@ class Advanced extends Component $this->allowed_ips = $this->settings->allowed_ips; $this->do_not_track = $this->settings->do_not_track; $this->is_registration_enabled = $this->settings->is_registration_enabled; + $this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled; $this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled; $this->is_api_enabled = $this->settings->is_api_enabled; $this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation; @@ -199,6 +204,7 @@ class Advanced extends Component try { $this->authorize('update', $this->settings); $this->settings->is_registration_enabled = $this->is_registration_enabled; + $this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled; $this->settings->do_not_track = $this->do_not_track; $this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled; $this->settings->custom_dns_servers = $this->custom_dns_servers; diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 9bca0db2e3..1426f61f02 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -160,30 +160,59 @@ class SettingsEmail extends Component $this->instantSave('Resend'); } + public function toggleSmtp() + { + try { + $this->resetErrorBag(); + + if ($this->smtpEnabled) { + $this->smtpEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'SMTP settings updated.'); + } else { + $this->validateSmtpSettings(); + $this->smtpEnabled = true; + $this->resendEnabled = false; + $this->submitSmtp(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + + public function toggleResend() + { + try { + $this->resetErrorBag(); + + if ($this->resendEnabled) { + $this->resendEnabled = false; + $this->syncData(true); + $this->dispatch('success', 'Resend settings updated.'); + } else { + $this->validateResendSettings(); + $this->resendEnabled = true; + $this->smtpEnabled = false; + $this->submitResend(); + } + } catch (\Throwable $e) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function submitSmtp() { try { $this->authorize('update', $this->settings); - $this->validate([ - 'smtpEnabled' => 'boolean', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - 'smtpHost' => 'required|string', - 'smtpPort' => 'required|numeric', - 'smtpEncryption' => 'required|string|in:starttls,tls,none', - 'smtpUsername' => 'nullable|string', - 'smtpPassword' => 'nullable|string', - 'smtpTimeout' => 'nullable|numeric', - 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], - ], [ - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - 'smtpHost.required' => 'SMTP Host is required.', - 'smtpPort.required' => 'SMTP Port is required.', - 'smtpPort.numeric' => 'SMTP Port must be a number.', - 'smtpEncryption.required' => 'Encryption type is required.', - ]); + $this->validateSmtpSettings(); + + if ($this->smtpEnabled) { + $this->settings->resend_enabled = $this->resendEnabled = false; + } $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -210,17 +239,11 @@ class SettingsEmail extends Component { try { $this->authorize('update', $this->settings); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', - 'smtpFromAddress' => 'required|email', - 'smtpFromName' => 'required|string', - ], [ - 'resendApiKey.required' => 'Resend API Key is required.', - 'smtpFromAddress.required' => 'From Address is required.', - 'smtpFromAddress.email' => 'Please enter a valid email address.', - 'smtpFromName.required' => 'From Name is required.', - ]); + $this->validateResendSettings(); + + if ($this->resendEnabled) { + $this->settings->smtp_enabled = $this->smtpEnabled = false; + } $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -237,6 +260,45 @@ class SettingsEmail extends Component } } + private function validateSmtpSettings(): void + { + $this->validate([ + 'smtpEnabled' => 'boolean', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + 'smtpHost' => 'required|string', + 'smtpPort' => 'required|numeric', + 'smtpEncryption' => 'required|string|in:starttls,tls,none', + 'smtpUsername' => 'nullable|string', + 'smtpPassword' => 'nullable|string', + 'smtpTimeout' => 'nullable|numeric', + 'smtpEhloDomain' => ['nullable', 'string', new ValidHostname], + ], [ + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + 'smtpHost.required' => 'SMTP Host is required.', + 'smtpPort.required' => 'SMTP Port is required.', + 'smtpPort.numeric' => 'SMTP Port must be a number.', + 'smtpEncryption.required' => 'Encryption type is required.', + ]); + } + + private function validateResendSettings(): void + { + $this->validate([ + 'resendEnabled' => 'boolean', + 'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string', + 'smtpFromAddress' => 'required|email', + 'smtpFromName' => 'required|string', + ], [ + 'resendApiKey.required' => 'Resend API Key is required.', + 'smtpFromAddress.required' => 'From Address is required.', + 'smtpFromAddress.email' => 'Please enter a valid email address.', + 'smtpFromName.required' => 'From Name is required.', + ]); + } + public function sendTestEmail() { try { diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 4082718191..3b24d0cd2e 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,53 +2,89 @@ namespace App\Livewire; +use App\Models\InstanceSettings; use App\Models\OauthSetting; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; +use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { use AuthorizesRequests; + public InstanceSettings $settings; + public $oauth_settings_map; - protected function rules() + public ?string $selectedProvider = null; + + public bool $disable_registration_when_oauth_enabled = false; + + protected function rules(): array { - return OauthSetting::all()->reduce(function ($carry, $setting) { - $carry["oauth_settings_map.$setting->provider.enabled"] = 'required'; - $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable'; - $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable'; + return $this->validationRules(); + } + + private function validationRules(?string $provider = null): array + { + $rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) { + if ($provider !== null && $setting->provider !== $provider) { + return $carry; + } + + $carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean'; + $carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string'; + $carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https'; + $carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255'; + $carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000'; + $carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean'; + $carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600'; return $carry; }, []); + + if ($provider === null) { + $rules['disable_registration_when_oauth_enabled'] = 'boolean'; + } + + return $rules; } - public function mount() + public function mount(?string $provider = null): ?RedirectResponse { if (! isInstanceAdmin()) { return redirect()->route('home'); } - $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { - $carry[$setting->provider] = [ - 'id' => $setting->id, - 'provider' => $setting->provider, - 'enabled' => $setting->enabled, - 'client_id' => $setting->client_id, - 'client_secret' => $setting->client_secret, - 'redirect_uri' => $setting->redirect_uri, - 'tenant' => $setting->tenant, - 'base_url' => $setting->base_url, - ]; - return $carry; - }, []); + $this->settings = instanceSettings(); + $this->selectedProvider = $provider; + $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; + $this->oauth_settings_map = OauthSetting::all() + ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) + ->reduce(function ($carry, $setting) { + $carry[$setting->provider] = $this->oauthSettingToArray($setting); + + return $carry; + }, []); + + if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { + abort(404); + } + + return null; } - private function updateOauthSettings(?string $provider = null) + private function updateOauthSettings(?string $provider = null): void { + $this->validate($this->validationRules($provider)); + if ($provider) { $oauthData = $this->oauth_settings_map[$provider]; $oauth = OauthSetting::find($oauthData['id']); @@ -57,78 +93,128 @@ class SettingsOauth extends Component throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - $oauth->fill([ - 'enabled' => $oauthData['enabled'], - 'client_id' => $oauthData['client_id'], - 'client_secret' => $oauthData['client_secret'], - 'redirect_uri' => $oauthData['redirect_uri'], - 'tenant' => $oauthData['tenant'], - 'base_url' => $oauthData['base_url'], - ]); - - if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->update(['enabled' => false]); - throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); - } + $this->fillOauthSetting($oauth, $oauthData); + $this->ensureProviderCanBeEnabled($oauth); $oauth->save(); - // Update the array with fresh data - $this->oauth_settings_map[$provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); $this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!'); - } else { - $errors = []; - foreach (array_values($this->oauth_settings_map) as $settingData) { - $oauth = OauthSetting::find($settingData['id']); - if (! $oauth) { - $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; - - continue; - } - - $oauth->fill([ - 'enabled' => $settingData['enabled'], - 'client_id' => $settingData['client_id'], - 'client_secret' => $settingData['client_secret'], - 'redirect_uri' => $settingData['redirect_uri'], - 'tenant' => $settingData['tenant'], - 'base_url' => $settingData['base_url'], - ]); - - if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) { - $oauth->enabled = false; - $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; - } - - $oauth->save(); - - // Update the array with fresh data - $this->oauth_settings_map[$oauth->provider] = [ - 'id' => $oauth->id, - 'provider' => $oauth->provider, - 'enabled' => $oauth->enabled, - 'client_id' => $oauth->client_id, - 'client_secret' => $oauth->client_secret, - 'redirect_uri' => $oauth->redirect_uri, - 'tenant' => $oauth->tenant, - 'base_url' => $oauth->base_url, - ]; - } - - if (! empty($errors)) { - $this->dispatch('error', implode('
', $errors)); - } + return; } + + $errors = []; + foreach (array_values($this->oauth_settings_map) as $settingData) { + $oauth = OauthSetting::find($settingData['id']); + + if (! $oauth) { + $errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted."; + + continue; + } + + $this->fillOauthSetting($oauth, $settingData); + + if ($oauth->enabled && ! $oauth->couldBeEnabled()) { + $oauth->enabled = false; + $errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled."; + } + + if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->enabled = false; + $errors[] = "OIDC scopes must include 'openid'. The provider has been disabled."; + } + + $oauth->save(); + $this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth); + } + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + if (! empty($errors)) { + $this->dispatch('error', implode('
', $errors)); + } + } + + private function fillOauthSetting(OauthSetting $oauth, array $data): void + { + $oauth->fill([ + 'enabled' => (bool) ($data['enabled'] ?? false), + 'client_id' => $data['client_id'] ?? null, + 'client_secret' => $data['client_secret'] ?? null, + 'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null), + 'tenant' => $data['tenant'] ?? null, + 'base_url' => $this->nullableString($data['base_url'] ?? null), + 'custom_label' => $data['custom_label'] ?? null, + 'scopes' => $data['scopes'] ?? null, + 'allow_registration' => (bool) ($data['allow_registration'] ?? false), + 'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false), + 'require_email_verified' => (bool) ($data['require_email_verified'] ?? true), + 'use_pkce' => (bool) ($data['use_pkce'] ?? true), + 'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60), + ]); + } + + private function nullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private function ensureProviderCanBeEnabled(OauthSetting $oauth): void + { + if (! $oauth->enabled) { + return; + } + + if (! $oauth->couldBeEnabled()) { + $oauth->update(['enabled' => false]); + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); + } + + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + $oauth->update(['enabled' => false]); + throw new \Exception("OIDC scopes must include 'openid'."); + } + } + + private function oauthSettingToArray(OauthSetting $setting): array + { + return [ + 'id' => $setting->id, + 'provider' => $setting->provider, + 'enabled' => $setting->enabled, + 'client_id' => $setting->client_id, + 'client_secret' => $setting->client_secret, + 'redirect_uri' => $setting->redirect_uri, + 'tenant' => $setting->tenant, + 'base_url' => $setting->base_url, + 'custom_label' => $setting->custom_label, + 'scopes' => $setting->scopes ?: 'openid email profile', + 'allow_registration' => $setting->allow_registration, + 'auto_join_root_team' => $setting->auto_join_root_team, + 'require_email_verified' => $setting->require_email_verified ?? true, + 'use_pkce' => $setting->use_pkce ?? true, + 'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60, + 'label' => $this->providerLabel($setting->provider), + ]; + } + + public function providerLabel(string $provider): string + { + return match ($provider) { + 'oidc' => 'OpenID Connect', + 'gitlab' => 'GitLab', + default => str($provider)->headline()->toString(), + }; } public function instantSave(string $provider) @@ -141,56 +227,88 @@ class SettingsOauth extends Component } } - public function toggleProvider(string $provider): mixed + public function toggleProvider(string $provider) { try { $this->authorize('update', instanceSettings()); if (! array_key_exists($provider, $this->oauth_settings_map)) { - throw new \Exception('OAuth provider not found.'); + abort(404); } - $enabling = ! $this->oauth_settings_map[$provider]['enabled']; - if ($enabling) { - $this->validate($this->providerRules($provider)); + if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { + $this->validateProviderCanBeEnabled($provider); } - $this->oauth_settings_map[$provider]['enabled'] = $enabling; + $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; $this->updateOauthSettings($provider); - } catch (\Throwable $e) { + } catch (\Exception $e) { + $oauth = OauthSetting::where('provider', $provider)->first(); + if ($oauth) { + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + } + return handleError($e, $this); } - - return null; } - private function providerRules(string $provider): array + private function validateProviderCanBeEnabled(string $provider): void { - $prefix = "oauth_settings_map.$provider"; - $rules = [ - "$prefix.client_id" => 'required', - "$prefix.client_secret" => 'required', - ]; + $this->validate($this->validationRules($provider)); - if ($provider === 'azure') { - $rules["$prefix.tenant"] = 'required'; + $oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']); + if (! $oauth) { + throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.'); } - if (in_array($provider, ['authentik', 'clerk'], true)) { - $rules["$prefix.base_url"] = 'required'; + $this->fillOauthSetting($oauth, [ + ...$this->oauth_settings_map[$provider], + 'enabled' => true, + ]); + + if (! $oauth->couldBeEnabled()) { + throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.
Please fill in all required fields.'); } - return $rules; + if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + throw new \Exception("OIDC scopes must include 'openid'."); + } } - public function submit() + public function saveRegistrationPolicy(): void + { + $this->authorize('update', instanceSettings()); + $this->validate([ + 'disable_registration_when_oauth_enabled' => 'boolean', + ]); + + instanceSettings()->update([ + 'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled, + ]); + + $this->dispatch('success', 'Authentication settings updated successfully!'); + } + + public function submit(): void { try { $this->authorize('update', instanceSettings()); - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); - } catch (\Throwable $e) { - return handleError($e, $this); + $this->updateOauthSettings($this->selectedProvider); + + if ($this->selectedProvider === null) { + $this->dispatch('success', 'Instance settings updated successfully!'); + } + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + if ($this->selectedProvider !== null) { + $oauth = OauthSetting::where('provider', $this->selectedProvider)->first(); + if ($oauth) { + $this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth); + } + } + + handleError($e, $this); } } } diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 89188b31b1..70c9013af2 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -302,6 +302,23 @@ class EnvironmentVariable extends BaseModel return $real_value; } + public function resolveReferencedValue(): ?string + { + $value = $this->value; + + if ($this->is_literal || blank($value) || ! str($value)->startsWith('$')) { + return $value; + } + + $referencedKey = str($value)->after('$')->trim('{}')->value(); + + return static::where('resourceable_type', $this->resourceable_type) + ->where('resourceable_id', $this->resourceable_id) + ->where('is_preview', (bool) $this->is_preview) + ->where('key', $referencedKey) + ->first()?->value ?? $value; + } + private function get_real_environment_variables(?string $environment_variable = null, $resource = null) { return $this->get_real_environment_variables_internal($environment_variable, $resource); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index eb01fa7ada..02f3e7ed50 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -22,6 +22,7 @@ class InstanceSettings extends Model 'do_not_track', 'is_auto_update_enabled', 'is_registration_enabled', + 'disable_registration_when_oauth_enabled', 'next_channel', 'smtp_enabled', 'smtp_from_address', @@ -88,6 +89,8 @@ class InstanceSettings extends Model 'allowed_ip_ranges' => 'array', 'is_auto_update_enabled' => 'boolean', + 'is_registration_enabled' => 'boolean', + 'disable_registration_when_oauth_enabled' => 'boolean', 'auto_update_frequency' => 'string', 'update_check_frequency' => 'string', 'sentinel_token' => 'encrypted', @@ -115,6 +118,19 @@ class InstanceSettings extends Model }); } + public function isPasswordRegistrationAllowed(): bool + { + if (! $this->is_registration_enabled) { + return false; + } + + if (! $this->disable_registration_when_oauth_enabled) { + return true; + } + + return ! OauthSetting::where('enabled', true)->exists(); + } + public function fqdn(): Attribute { return Attribute::make( diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php new file mode 100644 index 0000000000..20541f6139 --- /dev/null +++ b/app/Models/IntegrationToken.php @@ -0,0 +1,38 @@ + 'encrypted', + 'capabilities' => 'array', + ]; + } + + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + public static function ownedByCurrentTeam() + { + return self::query()->where('team_id', currentTeam()->id); + } +} diff --git a/app/Models/OauthIdentity.php b/app/Models/OauthIdentity.php new file mode 100644 index 0000000000..1edf71ad2f --- /dev/null +++ b/app/Models/OauthIdentity.php @@ -0,0 +1,35 @@ + 'array', + 'last_login_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index e7999134a6..7765e41160 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,7 +11,19 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'allow_registration' => 'boolean', + 'auto_join_root_team' => 'boolean', + 'require_email_verified' => 'boolean', + 'use_pkce' => 'boolean', + 'clock_skew_seconds' => 'integer', + ]; + } protected $hidden = [ 'client_secret', @@ -32,9 +44,46 @@ class OauthSetting extends Model return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant); case 'authentik': case 'clerk': + case 'oidc': return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url); default: return filled($this->client_id) && filled($this->client_secret); } } + + /** + * @return array + */ + public function scopeList(): array + { + $scopes = str($this->scopes ?: 'openid email profile') + ->replace(',', ' ') + ->explode(' ') + ->map(fn (string $scope) => trim($scope)) + ->filter() + ->unique() + ->values() + ->all(); + + return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes; + } + + public function loginLabel(): string + { + if (filled($this->custom_label)) { + return $this->custom_label; + } + + $envLabel = config("services.{$this->provider}.custom_label"); + if (filled($envLabel)) { + return $envLabel; + } + + return __("auth.login.{$this->provider}"); + } + + public function isOidc(): bool + { + return $this->provider === 'oidc'; + } } diff --git a/app/Models/Team.php b/app/Models/Team.php index 15085203aa..b7664e94d3 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -304,6 +304,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(CloudProviderToken::class); } + public function integrationTokens() + { + return $this->hasMany(IntegrationToken::class); + } + public function sources() { $sources = collect([]); diff --git a/app/Models/User.php b/app/Models/User.php index 5b38473962..10303422bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ use App\Services\ChangelogService; use App\Traits\DeletesUserSessions; use DateTimeInterface; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notifiable; @@ -507,12 +508,26 @@ class User extends Authenticatable implements SendsEmail && Carbon::now()->lessThan($this->email_change_code_expires_at); } + public function oauthIdentities(): HasMany + { + return $this->hasMany(OauthIdentity::class); + } + + public function hasSsoIdentity(): bool + { + return $this->oauthIdentities()->exists(); + } + /** * Check if the user has a password set. - * OAuth users are created without passwords. */ public function hasPassword(): bool { return ! empty($this->password); } + + public function requiresPasswordConfirmation(): bool + { + return $this->hasPassword() && ! $this->hasSsoIdentity(); + } } diff --git a/app/Policies/IntegrationTokenPolicy.php b/app/Policies/IntegrationTokenPolicy.php new file mode 100644 index 0000000000..309c8167f2 --- /dev/null +++ b/app/Policies/IntegrationTokenPolicy.php @@ -0,0 +1,34 @@ +isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function view(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function update(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } + + public function delete(User $user, IntegrationToken $integrationToken): bool + { + return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5856791662..e4d2b0a851 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Auth\Oidc\OidcDiscoveryService; +use App\Auth\Oidc\OidcTokenValidator; +use App\Auth\Oidc\Socialite\OidcProvider; use App\Models\PersonalAccessToken; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\App; @@ -10,6 +13,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; +use Laravel\Socialite\Contracts\Factory as SocialiteFactory; use Stripe\StripeClient; class AppServiceProvider extends ServiceProvider @@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureCommands(); - $this->configureModels(); $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); } + private function configureOidcSocialite(): void + { + if (! $this->app->bound(SocialiteFactory::class)) { + return; + } + + $this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) { + return new OidcProvider( + $app['request'], + $app->make(OidcDiscoveryService::class), + $app->make(OidcTokenValidator::class), + '', + '', + '', + ); + }); + } + private function configureGitHubHttp(): void { Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { @@ -77,16 +98,5 @@ class AppServiceProvider extends ServiceProvider ])->baseUrl($api_url); } }); - - Http::macro('GitLab', function (string $api_url, ?string $access_token = null) { - $client = Http::withHeaders([ - 'Accept' => 'application/json', - ])->baseUrl($api_url); - if ($access_token) { - $client = $client->withToken($access_token); - } - - return $client; - }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 09b2a3e089..e8e6fb42c6 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; +use App\Models\IntegrationToken; use App\Models\PrivateKey; use App\Models\Project; use App\Models\PushoverNotificationSettings; @@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; +use App\Policies\IntegrationTokenPolicy; use App\Policies\NotificationPolicy; use App\Policies\PrivateKeyPolicy; use App\Policies\ProjectPolicy; @@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, + IntegrationToken::class => IntegrationTokenPolicy::class, CloudInitScript::class => CloudInitScriptPolicy::class, Tag::class => TagPolicy::class, diff --git a/app/Providers/DuskServiceProvider.php b/app/Providers/DuskServiceProvider.php deleted file mode 100644 index 07e0e8709f..0000000000 --- a/app/Providers/DuskServiceProvider.php +++ /dev/null @@ -1,21 +0,0 @@ -visit('/login') - ->type('email', 'test@example.com') - ->type('password', 'password') - ->press('Login'); - }); - } -} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 65d9687744..dfa3bb3314 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { return redirect()->route('login'); } @@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // If there are no users, redirect to registration + if ($users == 0 && $settings->isPasswordRegistrationAllowed()) { + // If there are no users and password registration is allowed, redirect to registration. return redirect()->route('register'); } return view('auth.login', [ - 'is_registration_enabled' => $settings->is_registration_enabled, + 'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(), 'enabled_oauth_providers' => $enabled_oauth_providers, ]); }); diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php new file mode 100644 index 0000000000..2ec8f88e3e --- /dev/null +++ b/app/Services/Auth/OauthLoginService.php @@ -0,0 +1,228 @@ +email)); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new HttpException(403, 'OAuth provider did not return a valid email address'); + } + + $user = $provider === 'oidc' + ? $this->resolveOidcUser($oauthUser, $oauthSetting, $email) + : $this->resolveOauthUser($oauthUser, $oauthSetting, $email); + + Auth::login($user); + $team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team(); + session(['currentTeam' => $user->currentTeam = $team]); + + return $user; + } + + private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $provider = $oauthSetting->provider; + $providerUserId = $oauthUser->id ?? null; + if ( + (! is_string($providerUserId) && ! is_int($providerUserId)) + || (is_string($providerUserId) && trim($providerUserId) === '') + ) { + throw new HttpException(403, 'OAuth provider did not return a valid user ID'); + } + $providerUserId = (string) $providerUserId; + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'issuer' => $provider, + 'provider_user_id' => $providerUserId, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User + { + $issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer) + ? $oauthUser->issuer + : data_get($oauthUser->user, 'iss'); + $subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject) + ? $oauthUser->subject + : data_get($oauthUser->user, 'sub', $oauthUser->id); + $emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified) + || data_get($oauthUser->user, 'email_verified') === true; + + if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') { + throw new HttpException(403, 'OIDC provider did not return issuer and subject claims'); + } + + if ($oauthSetting->require_email_verified && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider did not verify the email address'); + } + + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + $identityKey = [ + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + ]; + + try { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User { + $identity = OauthIdentity::where($identityKey)->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $identity->user; + } + + $user = User::whereEmail($email)->first(); + + // Linking a new OIDC identity to an existing local account by email + // is account takeover unless the provider attests the email. This + // guard is independent of the require_email_verified toggle, which + // only governs the broader login flow. + if ($user && ! $emailVerified) { + throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account'); + } + + if (! $user) { + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + 'email' => $email, + 'raw_claims' => $rawClaims, + 'last_login_at' => now(), + ]); + + return $user; + }); + } catch (UniqueConstraintViolationException $exception) { + return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception; + } + } + + private function canCreateUser(OauthSetting $oauthSetting): bool + { + return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; + } + + private function createUser(string $name, string $email, OauthSetting $oauthSetting): User + { + if (User::count() === 0) { + $user = (new User)->forceFill([ + 'id' => 0, + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + $user->save(); + + $team = $user->teams()->first() ?? Team::find(0); + if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + instanceSettings()->update(['is_registration_enabled' => false]); + + return $user; + } + + if ($oauthSetting->auto_join_root_team) { + return $this->createRootTeamOnlyUser($name, $email); + } + + return User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + } + + private function createRootTeamOnlyUser(string $name, string $email): User + { + return DB::transaction(function () use ($name, $email) { + $rootTeam = Team::find(0); + if ($rootTeam === null) { + throw new HttpException(403, 'Root team is not available for OAuth user provisioning'); + } + + $user = User::withoutEvents(fn () => User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ])); + + $user->teams()->attach($rootTeam, ['role' => 'member']); + + return $user; + }); + } +} diff --git a/app/Services/CloudflareTokenValidator.php b/app/Services/CloudflareTokenValidator.php new file mode 100644 index 0000000000..2a4a761027 --- /dev/null +++ b/app/Services/CloudflareTokenValidator.php @@ -0,0 +1,42 @@ +client($token); + $verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify'); + + if (! $verification->successful() || $verification->json('result.status') !== 'active') { + return false; + } + + if (in_array('dns', $capabilities, true)) { + $zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]); + $zoneId = $zones->json('result.0.id'); + + if (! $zones->successful() || ! is_string($zoneId)) { + return false; + } + + return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [ + 'per_page' => 1, + ])->successful(); + } + + return true; + } + + private function client(string $token): PendingRequest + { + return Http::withToken($token) + ->acceptJson() + ->connectTimeout(5) + ->timeout(10); + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a003ec40d..461e7c2669 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string * Check if password confirmation should be skipped. * Returns true if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * Used by modal-confirmation.blade.php to determine if password step should be shown. * @@ -4566,8 +4566,9 @@ function shouldSkipPasswordConfirmation(): bool return true; } - // Skip if user has no password (OAuth users) - if (! Auth::user()?->hasPassword()) { + // OAuth users may have an unusable generated password, so the linked + // identity is the source of truth for whether confirmation is possible. + if (! Auth::user()?->requiresPasswordConfirmation()) { return true; } @@ -4578,7 +4579,7 @@ function shouldSkipPasswordConfirmation(): bool * Verify password for two-step confirmation. * Skips verification if: * - Two-step confirmation is globally disabled - * - User has no password (OAuth users) + * - User has no usable local password confirmation (including SSO users) * * @param mixed $password The password to verify (may be array if skipped by frontend) * @param Component|null $component Optional Livewire component to add errors to diff --git a/bootstrap/helpers/socialite.php b/bootstrap/helpers/socialite.php index fd3fbe74ba..f177e6c16f 100644 --- a/bootstrap/helpers/socialite.php +++ b/bootstrap/helpers/socialite.php @@ -1,7 +1,13 @@ client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -23,7 +29,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'authentik' || $provider == 'clerk') { - $authentik_clerk_config = new \SocialiteProviders\Manager\Config( + $authentik_clerk_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -34,7 +40,7 @@ function get_socialite_provider(string $provider) } if ($provider == 'zitadel') { - $zitadel_config = new \SocialiteProviders\Manager\Config( + $zitadel_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri, @@ -44,8 +50,12 @@ function get_socialite_provider(string $provider) return Socialite::driver('zitadel')->setConfig($zitadel_config); } + if ($provider === 'oidc') { + return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting)); + } + if ($provider == 'google') { - $google_config = new \SocialiteProviders\Manager\Config( + $google_config = new Config( $oauth_setting->client_id, $oauth_setting->client_secret, $oauth_setting->redirect_uri @@ -63,11 +73,11 @@ function get_socialite_provider(string $provider) ]; $provider_class_map = [ - 'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class, - 'discord' => \SocialiteProviders\Discord\Provider::class, - 'github' => \Laravel\Socialite\Two\GithubProvider::class, - 'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class, - 'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class, + 'bitbucket' => BitbucketProvider::class, + 'discord' => Provider::class, + 'github' => GithubProvider::class, + 'gitlab' => GitlabProvider::class, + 'infomaniak' => SocialiteProviders\Infomaniak\Provider::class, ]; $socialite = Socialite::buildProvider( diff --git a/composer.json b/composer.json index 871c6f010c..c0ffc6f07f 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.2.1", "doctrine/dbal": "^4.4.4", + "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.15.3", "laravel/fortify": "^1.37.3", "laravel/framework": "^12.65.0", @@ -63,7 +64,6 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "laravel/boost": "^2.4.8", - "laravel/dusk": "^8.6.0", "laravel/pint": "^1.30.4", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", diff --git a/composer.lock b/composer.lock index c2c42ba71a..b77aef46f5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "971daeb1b3078a36428c0fb56bb895b7", + "content-hash": "13e5d201c34a64cdf53e80a21304c9d5", "packages": [ { "name": "aws/aws-crt-php", @@ -13698,80 +13698,6 @@ }, "time": "2026-05-19T20:09:50+00:00" }, - { - "name": "laravel/dusk", - "version": "v8.6.0", - "source": { - "type": "git", - "url": "https://github.com/laravel/dusk.git", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143", - "shasum": "" - }, - "require": { - "ext-json": "*", - "ext-zip": "*", - "guzzlehttp/guzzle": "^7.5", - "illuminate/console": "^10.0|^11.0|^12.0|^13.0", - "illuminate/support": "^10.0|^11.0|^12.0|^13.0", - "php": "^8.1", - "php-webdriver/webdriver": "^1.15.2", - "symfony/console": "^6.2|^7.0|^8.0", - "symfony/finder": "^6.2|^7.0|^8.0", - "symfony/process": "^6.2|^7.0|^8.0", - "vlucas/phpdotenv": "^5.2" - }, - "require-dev": { - "laravel/framework": "^10.0|^11.0|^12.0|^13.0", - "mockery/mockery": "^1.6", - "orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.1|^11.0|^12.0.1", - "psy/psysh": "^0.11.12|^0.12", - "symfony/yaml": "^6.2|^7.0|^8.0" - }, - "suggest": { - "ext-pcntl": "Used to gracefully terminate Dusk when tests are running." - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "Laravel\\Dusk\\DuskServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "Laravel\\Dusk\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - } - ], - "description": "Laravel Dusk provides simple end-to-end testing and browser automation.", - "keywords": [ - "laravel", - "testing", - "webdriver" - ], - "support": { - "issues": "https://github.com/laravel/dusk/issues", - "source": "https://github.com/laravel/dusk/tree/v8.6.0" - }, - "time": "2026-04-15T14:50:40+00:00" - }, { "name": "laravel/pint", "version": "v1.30.4", @@ -14817,72 +14743,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "php-webdriver/webdriver", - "version": "1.16.0", - "source": { - "type": "git", - "url": "https://github.com/php-webdriver/php-webdriver.git", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a", - "reference": "ac0662863aa120b4f645869f584013e4c4dba46a", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-zip": "*", - "php": "^7.3 || ^8.0", - "symfony/polyfill-mbstring": "^1.12", - "symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "replace": { - "facebook/webdriver": "*" - }, - "require-dev": { - "ergebnis/composer-normalize": "^2.20.0", - "ondram/ci-detector": "^4.0", - "php-coveralls/php-coveralls": "^2.4", - "php-mock/php-mock-phpunit": "^2.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpunit/phpunit": "^9.3", - "squizlabs/php_codesniffer": "^3.5", - "symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0" - }, - "suggest": { - "ext-simplexml": "For Firefox profile creation" - }, - "type": "library", - "autoload": { - "files": [ - "lib/Exception/TimeoutException.php" - ], - "psr-4": { - "Facebook\\WebDriver\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.", - "homepage": "https://github.com/php-webdriver/php-webdriver", - "keywords": [ - "Chromedriver", - "geckodriver", - "php", - "selenium", - "webdriver" - ], - "support": { - "issues": "https://github.com/php-webdriver/php-webdriver/issues", - "source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0" - }, - "time": "2025-12-28T23:57:40+00:00" - }, { "name": "phpstan/phpstan", "version": "2.2.8", diff --git a/config/app.php b/config/app.php index 13a5b7d4b8..59aa6f4c28 100644 --- a/config/app.php +++ b/config/app.php @@ -193,8 +193,8 @@ return [ */ 'maintenance' => [ - 'driver' => 'cache', - 'store' => 'redis', + 'driver' => env('APP_MAINTENANCE_DRIVER', 'cache'), + 'store' => env('APP_MAINTENANCE_STORE', 'redis'), ], /* diff --git a/config/services.php b/config/services.php index c5956cf6c9..3a2a0631ef 100644 --- a/config/services.php +++ b/config/services.php @@ -60,6 +60,14 @@ return [ 'tenant' => env('GOOGLE_TENANT'), ], + 'oidc' => [ + 'client_id' => env('OIDC_CLIENT_ID'), + 'client_secret' => env('OIDC_CLIENT_SECRET'), + 'redirect' => env('OIDC_REDIRECT_URI'), + 'base_url' => env('OIDC_BASE_URL'), + 'custom_label' => env('OIDC_LOGIN_LABEL'), + ], + 'zitadel' => [ 'client_id' => env('ZITADEL_CLIENT_ID'), 'client_secret' => env('ZITADEL_CLIENT_SECRET'), diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 19c4445b26..13fe6b6784 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -8,6 +8,12 @@ return new class extends Migration /** * The configuration snapshot/diff now store an encrypted blob (not valid * JSON), so the columns must hold arbitrary text instead of json. + * + * Coolify's own backend runs exclusively on PostgreSQL in production and + * SQLite in testing (see config/database.php — the only configured + * connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed + * resources, never Coolify's application database, so no driver path is + * needed for them here. */ public function up(): void { diff --git a/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php new file mode 100644 index 0000000000..3160ef9ddb --- /dev/null +++ b/database/migrations/2026_06_04_091631_add_oidc_fields_to_oauth_settings_table.php @@ -0,0 +1,40 @@ +string('custom_label')->nullable(); + $table->string('scopes')->nullable(); + $table->boolean('allow_registration')->default(true); + $table->boolean('require_email_verified')->default(true); + $table->boolean('use_pkce')->default(true); + $table->unsignedSmallInteger('clock_skew_seconds')->default(60); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn([ + 'custom_label', + 'scopes', + 'allow_registration', + 'require_email_verified', + 'use_pkce', + 'clock_skew_seconds', + ]); + }); + } +}; diff --git a/database/migrations/2026_06_04_091631_create_oauth_identities_table.php b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php new file mode 100644 index 0000000000..9f838e5779 --- /dev/null +++ b/database/migrations/2026_06_04_091631_create_oauth_identities_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('issuer'); + $table->string('provider_user_id'); + $table->string('email')->nullable()->index(); + $table->json('raw_claims')->nullable(); + $table->timestamp('last_login_at')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_identities'); + } +}; diff --git a/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php new file mode 100644 index 0000000000..06c0f1dd52 --- /dev/null +++ b/database/migrations/2026_06_04_091632_add_oauth_registration_policy_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('disable_registration_when_oauth_enabled')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('disable_registration_when_oauth_enabled'); + }); + } +}; diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php new file mode 100644 index 0000000000..b0f5aad18a --- /dev/null +++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php @@ -0,0 +1,28 @@ +boolean('auto_join_root_team')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('oauth_settings', function (Blueprint $table) { + $table->dropColumn('auto_join_root_team'); + }); + } +}; diff --git a/database/migrations/2026_08_15_000000_create_integration_tokens_table.php b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php new file mode 100644 index 0000000000..a17d3972d5 --- /dev/null +++ b/database/migrations/2026_08_15_000000_create_integration_tokens_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('uuid')->unique(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->string('provider'); + $table->string('name'); + $table->text('token'); + $table->json('capabilities'); + $table->timestamps(); + + $table->index(['team_id', 'provider']); + }); + } + + public function down(): void + { + Schema::dropIfExists('integration_tokens'); + } +}; diff --git a/database/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e3e63defd..f916c4a9cd 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 2ac615cc01..19d3aa42e8 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,12 +15,10 @@ class UserSeeder extends Seeder 'email' => 'test@example.com', ]); User::factory()->create([ - 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ - 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); diff --git a/lang/de.json b/lang/de.json index 7c43300e67..cbc2237a75 100644 --- a/lang/de.json +++ b/lang/de.json @@ -7,6 +7,7 @@ "auth.login.github": "Mit GitHub anmelden", "auth.login.gitlab": "Mit GitLab anmelden", "auth.login.google": "Mit Google anmelden", + "auth.login.oidc": "Mit SSO anmelden", "auth.login.infomaniak": "Mit Infomaniak anmelden", "auth.login.zitadel": "Mit Zitadel anmelden", "auth.already_registered": "Bereits registriert?", diff --git a/lang/en.json b/lang/en.json index 12c21b6665..b97a10d629 100644 --- a/lang/en.json +++ b/lang/en.json @@ -8,6 +8,7 @@ "auth.login.github": "Login with GitHub", "auth.login.gitlab": "Login with Gitlab", "auth.login.google": "Login with Google", + "auth.login.oidc": "Login with SSO", "auth.login.infomaniak": "Login with Infomaniak", "auth.login.zitadel": "Login with Zitadel", "auth.already_registered": "Already registered?", diff --git a/lang/pl.json b/lang/pl.json index bcd8e23937..b05437ac4e 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -8,6 +8,7 @@ "auth.login.github": "Zaloguj się przez GitHub", "auth.login.gitlab": "Zaloguj się przez Gitlab", "auth.login.google": "Zaloguj się przez Google", + "auth.login.oidc": "Zaloguj się przez SSO", "auth.login.infomaniak": "Zaloguj się przez Infomaniak", "auth.login.zitadel": "Zaloguj się przez Zitadel", "auth.already_registered": "Już zarejestrowany?", diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg new file mode 100644 index 0000000000..9c542584ef --- /dev/null +++ b/public/svgs/oidc.svg @@ -0,0 +1,5 @@ + + OpenID Connect + + + diff --git a/resources/js/app.js b/resources/js/app.js index bb41b7f041..900ef8af71 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,3 +1,4 @@ +import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeTerminalComponent } from './terminal.js'; // Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate @@ -12,6 +13,7 @@ document.addEventListener('livewire:navigated', () => { // Keeping this registration independent from the current route also makes it // available before Alpine processes terminal markup after wire:navigate. document.addEventListener('alpine:init', initializeTerminalComponent); +document.addEventListener('alpine:init', initializeCopyButtonComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms diff --git a/resources/js/copy-button.js b/resources/js/copy-button.js new file mode 100644 index 0000000000..0ce8d5d67d --- /dev/null +++ b/resources/js/copy-button.js @@ -0,0 +1,35 @@ +// Alpine data provider for the component (x-data="copyButton"). +export function initializeCopyButtonComponent() { + window.Alpine.data('copyButton', () => ({ + copied: false, + async copy(value) { + if (value === null || value === undefined) { + window.toast('Value is not available.', { type: 'warning' }); + return; + } + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + // Deprecated, but the only copy path on plain http (non-secure contexts). + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!ok) { + throw new Error('Copy command was rejected.'); + } + } + this.copied = true; + setTimeout(() => (this.copied = false), 1200); + } catch (e) { + window.toast('Could not copy to clipboard.', { type: 'warning' }); + } + }, + })); +} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 829a26cad3..12eb57867c 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,11 +80,15 @@ @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) - {{ __("auth.login.$provider_setting->provider") }} + @if ($provider_setting->provider !== 'oidc') + + @endif + {{ $provider_setting->loginLabel() }} @endforeach
diff --git a/resources/views/components/copy-button.blade.php b/resources/views/components/copy-button.blade.php index dfdceef20b..3333a62bfa 100644 --- a/resources/views/components/copy-button.blade.php +++ b/resources/views/components/copy-button.blade.php @@ -1,22 +1,20 @@ @props([ - 'value', + 'value' => null, + 'resolve' => null, 'label' => 'Copy to clipboard', ]) - diff --git a/resources/views/components/forms/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php deleted file mode 100644 index e299610eb2..0000000000 --- a/resources/views/components/forms/copy-button.blade.php +++ /dev/null @@ -1,28 +0,0 @@ -@props(['text', 'label' => null]) - -
- @if ($label) - - @endif -
- - -
-
diff --git a/resources/views/components/forms/copy-input.blade.php b/resources/views/components/forms/copy-input.blade.php new file mode 100644 index 0000000000..d31fac0bca --- /dev/null +++ b/resources/views/components/forms/copy-input.blade.php @@ -0,0 +1,15 @@ +@props(['text', 'label' => null]) + +
+ @if ($label) + + @endif +
+ + +
+
diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index d63c1953f2..d0778dce4f 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -287,17 +287,8 @@
- +
diff --git a/resources/views/components/reicon.blade.php b/resources/views/components/reicon.blade.php index 04471497f5..a46e4194cf 100644 --- a/resources/views/components/reicon.blade.php +++ b/resources/views/components/reicon.blade.php @@ -63,6 +63,7 @@ 'upload' => '', 'x' => '', 'check' => '', + 'copy' => '', 'chevron-down' => '', 'trash' => '', 'external-link' => '', diff --git a/resources/views/components/security/settings-layout.blade.php b/resources/views/components/security/settings-layout.blade.php index d2b3e30a6f..a17b0b96a6 100644 --- a/resources/views/components/security/settings-layout.blade.php +++ b/resources/views/components/security/settings-layout.blade.php @@ -12,6 +12,12 @@ 'active' => request()->routeIs('security.cloud-tokens*'), 'icon' => 'cloud', ] : null, + auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [ + 'label' => 'Integration Tokens', + 'route' => 'security.integration-tokens', + 'active' => request()->routeIs('security.integration-tokens'), + 'icon' => 'network', + ] : null, auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [ 'label' => 'Cloud-Init Scripts', 'route' => 'security.cloud-init-scripts', diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index 0e0de551fd..dbe381e050 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -12,6 +12,24 @@ 'active' => $activeMenu === 'advanced', 'icon' => 'grid', ], + [ + 'label' => 'Authentication', + 'route' => 'settings.oauth', + 'active' => $activeMenu === 'oauth', + 'icon' => 'keys', + ], + [ + 'label' => 'Transactional Email', + 'route' => 'settings.email', + 'active' => $activeMenu === 'email', + 'icon' => 'notifications', + ], + [ + 'label' => 'Instance Backup', + 'route' => 'settings.backup', + 'active' => $activeMenu === 'backup', + 'icon' => 'database', + ], [ 'label' => 'Updates', 'route' => 'settings.updates', diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index a97d8c1df7..82b8cbcbdb 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -225,30 +225,6 @@ let checkHealthInterval = null; let checkIfIamDeadInterval = null; - async function copyToClipboard(text) { - try { - if (navigator.clipboard?.writeText && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!copied) { - throw new Error('Copy command was rejected.'); - } - } - window.Livewire.dispatch('success', 'Copied to clipboard.'); - } catch (error) { - window.Livewire.dispatch('error', 'Failed to copy to clipboard.'); - } - } - window.copyToClipboard = copyToClipboard; document.addEventListener('livewire:init', () => { window.Livewire.on('reloadWindow', (timeout) => { if (timeout) { diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index ef54d3e215..da5329a475 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -134,15 +134,22 @@
+ :disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)"> Change
-
- + + - + @endif
@@ -249,9 +257,9 @@
- - +
diff --git a/resources/views/livewire/project/application/internal-access.blade.php b/resources/views/livewire/project/application/internal-access.blade.php index 8ab1442ba5..6997b766b8 100644 --- a/resources/views/livewire/project/application/internal-access.blade.php +++ b/resources/views/livewire/project/application/internal-access.blade.php @@ -15,7 +15,7 @@

Internal access

@if ($currentInternalHostname) - + @else
@@ -25,9 +25,9 @@ readonly aria-live="polite">
@endif - - - + + +

diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 81c19bd3f0..42ade3da6e 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -116,25 +116,9 @@

Mount a Docker volume inside the container.

- @if ($isSwarm) -
Swarm Mode detected: You need to set a shared - volume - (EFS/NFS/etc) on all the worker nodes if you would like to use a - persistent - volumes.
- @endif
- @if ($isSwarm) - - @else - - @endif diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 923514efcc..87ecd69985 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -219,7 +219,8 @@ @else + :isPreview="$row['scope'] === 'preview'" :showEnvironmentType="$showEnvironmentType" + :resourceableType="get_class($resource)" :resourceableId="$resource->id" /> @endif @endforeach
diff --git a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php index 84d03c0fe8..5492d33f90 100644 --- a/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show-hardcoded.blade.php @@ -28,7 +28,10 @@ - - - -
+
+ @unless (auth()->user()?->isMember() ?? true) + + @endunless +
diff --git a/resources/views/livewire/project/shared/resource-details.blade.php b/resources/views/livewire/project/shared/resource-details.blade.php index 2e92c73146..1a032f6964 100644 --- a/resources/views/livewire/project/shared/resource-details.blade.php +++ b/resources/views/livewire/project/shared/resource-details.blade.php @@ -3,8 +3,8 @@

Resource

- - + +
@@ -12,8 +12,8 @@

Environment

- - + +
@endif @@ -22,8 +22,8 @@

Project

- - + +
@endif @@ -32,8 +32,8 @@

Server

- - + +
@endif @@ -43,10 +43,10 @@

Stack Sub-Resources

@foreach ($stack_applications as $item) - + @endforeach @foreach ($stack_databases as $item) - + @endforeach
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php index 25a4fd7492..dbe21fd7b8 100644 --- a/resources/views/livewire/project/shared/storages/all.blade.php +++ b/resources/views/livewire/project/shared/storages/all.blade.php @@ -154,7 +154,24 @@
Source Path - + @if (filled($form['hostPath'])) +
+
+ +
+ +
+ @else + - + @endif
diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 784843f6f0..40ea7b7e09 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -71,7 +71,7 @@ - + diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index c8c42763fa..6c87e3098d 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -39,7 +39,7 @@ - + @if ($githubManualWebhook && $gitlabManualWebhook) @@ -70,7 +70,7 @@

- + @can('update', $resource) - +
@endif diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 38db6aa3a6..80647458df 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -109,7 +109,12 @@ @if (session()->has('token')) - +
+ + +
@endif diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php new file mode 100644 index 0000000000..b7e53dbc7c --- /dev/null +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -0,0 +1,52 @@ +
+
+
+ + +
+ +
+
+ +
+ Capabilities +
+ +

+ Manage Cloudflare DNS records. +

+
+ @error('capabilities') + {{ $message }} + @enderror +
+ + @if (in_array('dns', $capabilities, true)) +
+
Required Cloudflare permissions
+
    +
  • Zone - DNS - Edit
  • +
  • Zone - Zone - Read
  • +
+ + Create a replacement token in Cloudflare + +
+ @endif + +
+ + + Validate and save + +
+
+
diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php new file mode 100644 index 0000000000..d847fff7fb --- /dev/null +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -0,0 +1,49 @@ +
+
+ + +
+ + +
+ +
+ Capabilities +
+ +

+ Manage Cloudflare DNS records. +

+
+ @error('capabilities') + {{ $message }} + @enderror +
+ + @if (in_array('dns', $capabilities, true)) +
+
Required Cloudflare permissions
+
    +
  • Zone - DNS - Edit
  • +
  • Zone - Zone - Read
  • +
+

Limit zone resources to the zones Coolify should manage.

+ + Create this token in Cloudflare + +
+ @endif + +
+ + Validate and add + +
+ +
diff --git a/resources/views/livewire/security/integration-tokens.blade.php b/resources/views/livewire/security/integration-tokens.blade.php new file mode 100644 index 0000000000..b4961551ae --- /dev/null +++ b/resources/views/livewire/security/integration-tokens.blade.php @@ -0,0 +1,84 @@ +
+ + Integration Tokens | Coolify + + + +
+ + + @can('create', App\Models\IntegrationToken::class) + + + + + + + @endcan + + + @if ($tokens->isEmpty()) + + @else +
+ @foreach ($tokens as $savedToken) +
+ + +
+
+

+ +

+
+
+ {{ ucfirst($savedToken->provider) }} +
+
+ +
+ +
+
+ +
+
+ @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/server/ca-certificate/show.blade.php b/resources/views/livewire/server/ca-certificate/show.blade.php index 94d2050dc2..2279e62e39 100644 --- a/resources/views/livewire/server/ca-certificate/show.blade.php +++ b/resources/views/livewire/server/ca-certificate/show.blade.php @@ -34,7 +34,7 @@

Read-only bind mount

-
diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index d490b6f1db..f1e4fc3f7a 100644 --- a/resources/views/livewire/server/security/patches.blade.php +++ b/resources/views/livewire/server/security/patches.blade.php @@ -35,8 +35,8 @@ - Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications - can be managed from + Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status + notifications can be managed from notification settings. diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 97822b9251..822c035b31 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -5,76 +5,126 @@ -
- -
+
+ +
+
- @foreach ($oauth_settings_map as $oauth_setting) - @php - $provider = $oauth_setting['provider']; - $providerLabel = str($provider)->headline(); - @endphp + + + + + @foreach ($oauth_settings_map as $provider => $oauth_setting) + title="{{ $oauth_setting['label'] }}">
- + if (!enabled) { + const invalidField = [...$el.closest('section').querySelectorAll('[required]')] + .find(field => !field.checkValidity()); + if (invalidField) { invalidField.reportValidity(); return; } + } + $wire.toggleProvider(provider); + "> {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
-
- - - +
+ @if ($provider === 'oidc') + + + + + + +
+ +
+ @else + + + + @endif @if ($provider === 'azure') - + @endif @if ($provider === 'google') - @endif @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) - + @endif + +
+ +
+ @if ($provider === 'oidc') + + + + @endif +
@endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index d15a1b87ab..d05ac5ac98 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -13,12 +13,19 @@
- + ]" /> + {{ $invite->link }} - +
', false); + + Livewire::test(SettingsOauth::class) + ->set('disable_registration_when_oauth_enabled', true) + ->call('saveRegistrationPolicy') + ->assertHasNoErrors() + ->assertDispatched('success'); + + expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); +}); + +it('shows oidc fields with a naked okta issuer url example', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('OpenID Connect') + ->assertSee('https://example.okta.com', false) + ->assertDontSee('/oauth2/default', false); +}); + +it('groups oidc fields in the expected desktop order', function () { + $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); + $fields = [ + 'redirect_uri', + 'base_url', + 'client_id', + 'client_secret', + 'scopes', + 'clock_skew_seconds', + 'custom_label', + ]; + $positions = array_map( + fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), + $fields, + ); + + expect($positions)->not->toContain(false) + ->and($positions)->toBe(collect($positions)->sort()->values()->all()) + ->and($view)->toContain('
'); +}); + +it('shows provider enable controls as settings section actions', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Enable') + ->assertDontSee('label="Enabled"', false) + ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); +}); + +it('stacks oidc option checkboxes vertically', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('Allow OIDC user creation') + ->assertSee('Require verified email') + ->assertSee('Use PKCE') + ->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false); +}); + +it('does not show unknown oauth providers', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get('/settings/oauth/unknown') + ->assertNotFound(); +}); + +it('defaults oidc user creation and verified email requirement to enabled', function () { + $setting = OauthSetting::where('provider', 'oidc')->first(); + + expect($setting->allow_registration)->toBeTrue() + ->and($setting->require_email_verified)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeFalse(); +}); + +it('persists oidc oauth settings from livewire', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.enabled', true) + ->set('oauth_settings_map.oidc.client_id', 'client-id') + ->set('oauth_settings_map.oidc.client_secret', 'secret') + ->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback') + ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') + ->set('oauth_settings_map.oidc.scopes', 'openid email profile groups') + ->set('oauth_settings_map.oidc.custom_label', 'Login with Okta') + ->set('oauth_settings_map.oidc.allow_registration', true) + ->set('oauth_settings_map.oidc.auto_join_root_team', true) + ->set('oauth_settings_map.oidc.require_email_verified', true) + ->set('disable_registration_when_oauth_enabled', true) + ->call('submit') + ->assertHasNoErrors(); + + $setting = OauthSetting::where('provider', 'oidc')->first(); + expect($setting->enabled)->toBeTrue() + ->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback') + ->and($setting->base_url)->toBe('https://idp.example.com') + ->and($setting->custom_label)->toBe('Login with Okta') + ->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) + ->and($setting->allow_registration)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeTrue(); + + expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); +}); + +it('saves only the selected provider from provider pages', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url') + ->set('oauth_settings_map.authentik.enabled', true) + ->set('oauth_settings_map.authentik.client_id', 'authentik-client') + ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') + ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') + ->call('submit') + ->assertHasNoErrors(); + + $setting = OauthSetting::where('provider', 'authentik')->first(); + expect($setting->enabled)->toBeTrue() + ->and($setting->client_id)->toBe('authentik-client') + ->and($setting->base_url)->toBe('https://authentik.example.com'); +}); + +it('validates oidc url fields before saving', function (string $field, string $value) { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.client_id', 'client-id') + ->set('oauth_settings_map.oidc.client_secret', 'secret') + ->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com') + ->set("oauth_settings_map.oidc.$field", $value) + ->call('submit') + ->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']); + + $setting = OauthSetting::where('provider', 'oidc')->first(); + expect($setting->{$field})->toBeNull(); +})->with([ + 'invalid redirect uri' => ['redirect_uri', 'not-a-url'], + 'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'], + 'invalid issuer url' => ['base_url', 'not-a-url'], + 'non-http issuer url' => ['base_url', 'ftp://idp.example.com'], +]); + +it('does not enable oidc without required fields', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class) + ->set('oauth_settings_map.oidc.enabled', true) + ->call('instantSave', 'oidc') + ->assertDispatched('error'); + + expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse(); +}); + +it('keeps provider disabled in the ui when enable validation fails', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->call('toggleProvider', 'authentik') + ->assertDispatched('error') + ->assertSet('oauth_settings_map.authentik.enabled', false); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); +}); + +it('disables an enabled provider gracefully when required fields become incomplete', function () { + actingAsInstanceAdmin(); + + OauthSetting::where('provider', 'authentik')->first()->forceFill([ + 'enabled' => true, + 'client_id' => 'authentik-client', + 'client_secret' => 'authentik-secret', + 'base_url' => 'https://authentik.example.com', + ])->save(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.authentik.client_secret', '') + ->call('submit') + ->assertDispatched('error') + ->assertSet('oauth_settings_map.authentik.enabled', false); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse(); +}); + +it('toggles provider enabled state from the action button', function () { + actingAsInstanceAdmin(); + + Livewire::test(SettingsOauth::class, ['provider' => 'authentik']) + ->set('oauth_settings_map.authentik.client_id', 'authentik-client') + ->set('oauth_settings_map.authentik.client_secret', 'authentik-secret') + ->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com') + ->call('toggleProvider', 'authentik') + ->assertHasNoErrors(); + + expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue(); +}); diff --git a/tests/Feature/SshMultiplexingLockTest.php b/tests/Feature/SshMultiplexingLockTest.php index 45e150dfab..272156fbd2 100644 --- a/tests/Feature/SshMultiplexingLockTest.php +++ b/tests/Feature/SshMultiplexingLockTest.php @@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f ->toContain('-o ControlMaster=auto') ->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}") ->toContain('-o ControlPersist=3600') - ->toContain("'bash -se' << \\") + ->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\") ->not->toContain('<< $delimiter'); Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN ')); diff --git a/tests/Feature/TeamInvitationUiTest.php b/tests/Feature/TeamInvitationUiTest.php index 13b6de23e5..949a301923 100644 --- a/tests/Feature/TeamInvitationUiTest.php +++ b/tests/Feature/TeamInvitationUiTest.php @@ -51,27 +51,21 @@ it('renders a real copy button for pending invitation links', function () { $view = file_get_contents(resource_path('views/livewire/team/invitations.blade.php')); expect($view) - ->toContain('aria-label="Copy invitation link"') - ->toContain('window.copyToClipboard(@js($invite->link))') - ->toContain('class="button h-7! shrink-0 px-2!"'); + ->toContain(''); Livewire::test(Invitations::class, [ 'invitations' => TeamInvitation::ownedByCurrentTeam()->get(), ]) ->assertSee($invitation->link) ->assertSeeHtml('aria-label="Copy invitation link"') - ->assertSeeHtml('window.copyToClipboard(') + ->assertSeeHtml('x-data="copyButton"') ->assertSeeHtml('type="button"'); }); -it('exposes a resilient global copyToClipboard helper', function () { +it('keeps clipboard logic in the shared copy button instead of a global helper', function () { $layout = file_get_contents(resource_path('views/layouts/base.blade.php')); - expect($layout) - ->toContain('async function copyToClipboard(text)') - ->toContain('window.copyToClipboard = copyToClipboard') - ->toContain('document.execCommand(\'copy\')') - ->toContain('window.isSecureContext'); + expect($layout)->not->toContain('copyToClipboard'); }); it('preserves a provisional user when revoking their invitation fails', function () { diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php new file mode 100644 index 0000000000..d8ccf86510 --- /dev/null +++ b/tests/Feature/UserSeederTest.php @@ -0,0 +1,16 @@ +seed(UserSeeder::class); + + $user = User::factory()->create(); + + expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) + ->and($user->id)->toBe(3); +}); diff --git a/tests/Unit/Actions/Server/AlpinePackageManagerTest.php b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php new file mode 100644 index 0000000000..d8050c84d9 --- /dev/null +++ b/tests/Unit/Actions/Server/AlpinePackageManagerTest.php @@ -0,0 +1,62 @@ +invoke(new InstallPrerequisites); + + expect($commands)->toContain('command -v bash >/dev/null || apk add bash'); +}); + +it('installs every Docker CLI plugin required on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand'); + + $command = $method->invoke(new InstallDocker); + + expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose'); +}); + +it('uses OpenRC instead of systemd to restart Docker on Alpine', function () { + $method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands'); + + $action = new InstallDocker; + $commands = $method->invoke($action, true); + + expect($commands) + ->toBe(['rc-update add docker default', 'rc-service docker restart']) + ->each->not->toContain('systemctl') + ->and($method->invoke($action, false)) + ->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']); +}); + +it('parses Alpine package updates', function () { + $method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput'); + $output = <<<'OUTPUT' +docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4] +libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0] +OUTPUT; + + $result = $method->invoke(new CheckUpdates, $output); + + expect($result)->toBe([ + 'total_updates' => 2, + 'updates' => [ + [ + 'package' => 'docker-cli-compose', + 'new_version' => '2.31.0-r5', + 'architecture' => 'x86_64', + 'current_version' => '2.31.0-r4', + ], + [ + 'package' => 'libcrypto3', + 'new_version' => '3.3.4-r0', + 'architecture' => 'aarch64', + 'current_version' => '3.3.3-r0', + ], + ], + ]); +}); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index b7901abb68..140be57643 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -334,13 +334,13 @@ it('detects environment variable value changes without exposing secret values', $change = collect($diff->changes())->firstWhere('label', 'API_TOKEN'); expect($change)->not->toBeNull() - ->and($change['display_summary'])->toBe('Changed') - ->and($change['old_display_value'])->toBe('••••••••') - ->and($change['new_display_value'])->toBe('••••••••') - ->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret'); + ->and($change['display_summary'])->toBeNull() + ->and($change['old_display_value'])->toBe('old-secret') + ->and($change['new_display_value'])->toBe('new-secret') + ->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret'); }); -it('describes added environment variables as set without exposing secret values', function () { +it('describes added unlocked environment variables with their value', function () { $application = snapshotTestApplication(); markSnapshotTestApplicationDeployed($application); @@ -361,6 +361,6 @@ it('describes added environment variables as set without exposing secret values' expect($change)->not->toBeNull() ->and($change['display_summary'])->toBeNull() ->and($change['old_display_value'])->toBe('-') - ->and($change['new_display_value'])->toBe('••••••••') - ->and(json_encode($diff->toArray()))->not->toContain('new-secret'); + ->and($change['new_display_value'])->toBe('new-secret') + ->and(json_encode($diff->toArray()))->toContain('new-secret'); }); diff --git a/tests/Unit/OauthSettingTest.php b/tests/Unit/OauthSettingTest.php new file mode 100644 index 0000000000..48fb50c375 --- /dev/null +++ b/tests/Unit/OauthSettingTest.php @@ -0,0 +1,30 @@ + 'oidc']); + expect($setting->couldBeEnabled())->toBeFalse(); + + $setting->fill([ + 'client_id' => 'client-id', + 'client_secret' => 'secret', + 'base_url' => 'https://idp.example.com', + ]); + + expect($setting->couldBeEnabled())->toBeTrue(); +}); + +it('returns configured scopes and custom login label', function () { + $setting = new OauthSetting([ + 'provider' => 'oidc', + 'scopes' => 'openid email profile groups', + 'custom_label' => 'Login with Okta', + ]); + + expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups']) + ->and($setting->loginLabel())->toBe('Login with Okta'); +}); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php new file mode 100644 index 0000000000..18c358fd13 --- /dev/null +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -0,0 +1,119 @@ + Http::response([ + 'issuer' => 'https://idp.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]), + 'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]), + ]); + + $service = app(OidcDiscoveryService::class); + + $discovery = $service->discover('https://idp.example.com'); + $jwks = $service->jwks($discovery->jwksUri); + + expect($discovery->issuer)->toBe('https://idp.example.com') + ->and($jwks['keys'][0]['kid'])->toBe('one'); + + Http::assertSentCount(2); + + $service->discover('https://idp.example.com'); + $service->jwks('https://idp.example.com/jwks'); + + Http::assertSentCount(2); +}); + +it('does not cache discovery documents with mismatched issuers', function () { + Cache::flush(); + Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration') + ->push([ + 'issuer' => 'https://evil.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]) + ->push([ + 'issuer' => 'https://idp.example.com', + 'authorization_endpoint' => 'https://idp.example.com/auth', + 'token_endpoint' => 'https://idp.example.com/token', + 'userinfo_endpoint' => 'https://idp.example.com/userinfo', + 'jwks_uri' => 'https://idp.example.com/jwks', + ]); + + $service = app(OidcDiscoveryService::class); + $cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com'); + + expect(fn () => $service->discover('https://idp.example.com')) + ->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.') + ->and(Cache::has($cacheKey))->toBeFalse() + ->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com'); + + Http::assertSentCount(2); +}); + +it('refetches jwks once on forced refresh to pick up rotated keys', function () { + Cache::flush(); + Http::fakeSequence('https://idp.example.com/jwks') + ->push(['keys' => [['kid' => 'old']]]) + ->push(['keys' => [['kid' => 'new']]]); + + $service = app(OidcDiscoveryService::class); + + expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old'); + + // Forced refresh bypasses the cache and sees the rotated key. + expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); + Http::assertSentCount(2); + + // Cooldown prevents a second immediate upstream fetch; cached value returned. + expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new'); + Http::assertSentCount(2); +}); + +it('rejects invalid discovery and jwks payloads', function () { + Cache::flush(); + Http::fake([ + 'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']), + ]); + + app(OidcDiscoveryService::class)->discover('https://bad.example.com'); +})->throws(OidcDiscoveryException::class); + +it('rejects jwks responses without keys', function () { + Cache::flush(); + Http::fake([ + 'https://idp.example.com/jwks' => Http::response(['empty' => true]), + ]); + + app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks'); +})->throws(OidcJwksException::class); + +it('rejects non-https issuer urls', function () { + Cache::flush(); + Http::fake(); + + app(OidcDiscoveryService::class)->discover('http://idp.example.com'); +})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.'); + +it('rejects non-https jwks uris', function () { + Cache::flush(); + Http::fake(); + + app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks'); +})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.'); diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php new file mode 100644 index 0000000000..b92ff58ffe --- /dev/null +++ b/tests/Unit/OidcProviderPkceTest.php @@ -0,0 +1,148 @@ +getAuthUrl($state); + } +} + +function oidc_provider_discovery_document(): OidcDiscoveryDocument +{ + return new OidcDiscoveryDocument( + issuer: 'https://idp.example.com', + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + jwksUri: 'https://idp.example.com/.well-known/jwks.json', + ); +} + +function oidc_provider_session(): Store +{ + $session = new Store('testing', new ArraySessionHandler(1200)); + $session->start(); + + return $session; +} + +function oidc_provider_request(Store $session, string $state = 'state-value'): Request +{ + $request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]); + $request->setLaravelSession($session); + + return $request; +} + +function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl +{ + /** @var OidcDiscoveryService&MockInterface $discoveryService */ + $discoveryService = Mockery::mock(OidcDiscoveryService::class); + $discoveryService->shouldReceive('discover') + ->byDefault() + ->with('https://idp.example.com') + ->andReturn(oidc_provider_discovery_document()); + + /** @var OidcTokenValidator&MockInterface $tokenValidator */ + $tokenValidator = Mockery::mock(OidcTokenValidator::class); + + return (new TestOidcProviderWithExposedAuthUrl( + $request, + $discoveryService, + $tokenValidator, + 'client-id', + 'client-secret', + 'https://coolify.example.com/auth/oidc/callback', + ))->setConfig(new OidcConfig( + issuerUrl: 'https://idp.example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://coolify.example.com/auth/oidc/callback', + usePkce: true, + )); +} + +it('stores oidc nonce and pkce verifier with a ten minute expiry', function () { + Carbon::setTestNow('2026-06-15 12:00:00'); + + try { + $session = oidc_provider_session(); + $provider = oidc_provider(oidc_provider_request($session)); + + $provider->authUrlForState('state-value'); + + $nonceEntry = $session->get('oidc.nonce.state-value'); + $verifierEntry = $session->get('oidc.code_verifier.state-value'); + + expect($nonceEntry)->toBeArray() + ->and($nonceEntry['value'])->toBeString()->not->toBeEmpty() + ->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp) + ->and($verifierEntry)->toBeArray() + ->and($verifierEntry['value'])->toBeString()->not->toBeEmpty() + ->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp); + } finally { + Carbon::setTestNow(); + } +}); + +it('sends a fresh oidc pkce verifier during token exchange', function () { + $session = oidc_provider_session(); + $session->put('oidc.code_verifier.state-value', [ + 'value' => 'fresh-verifier', + 'expires_at' => now()->addMinute()->timestamp, + ]); + + $provider = oidc_provider(oidc_provider_request($session)); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), + ])); + $handler->push(Middleware::history($history)); + $provider->setHttpClient(new Client(['handler' => $handler])); + + $provider->getAccessTokenResponse('authorization-code'); + + parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); + + expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier') + ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); +}); + +it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () { + $session = oidc_provider_session(); + $session->put('oidc.code_verifier.state-value', [ + 'value' => 'expired-verifier', + 'expires_at' => now()->subSecond()->timestamp, + ]); + + $provider = oidc_provider(oidc_provider_request($session)); + $history = []; + $handler = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)), + ])); + $handler->push(Middleware::history($history)); + $provider->setHttpClient(new Client(['handler' => $handler])); + + $provider->getAccessTokenResponse('authorization-code'); +})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php new file mode 100644 index 0000000000..9b1d9a24c3 --- /dev/null +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -0,0 +1,187 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($privateKey, $privatePem); + $details = openssl_pkey_get_details($privateKey); + + return [ + 'private_pem' => $privatePem, + 'jwks' => [ + 'keys' => [[ + 'kty' => 'RSA', + 'kid' => $kid, + 'alg' => 'RS256', + 'use' => 'sig', + 'n' => oidc_base64url($details['rsa']['n']), + 'e' => oidc_base64url($details['rsa']['e']), + ]], + ], + ]; +} + +function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string +{ + $header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR)); + $payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR)); + $signatureInput = $header.'.'.$payload; + openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256); + + return $signatureInput.'.'.oidc_base64url($signature); +} + +function oidc_discovery(): OidcDiscoveryDocument +{ + return new OidcDiscoveryDocument( + issuer: 'https://idp.example.com', + authorizationEndpoint: 'https://idp.example.com/oauth2/authorize', + tokenEndpoint: 'https://idp.example.com/oauth2/token', + userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo', + jwksUri: 'https://idp.example.com/.well-known/jwks.json', + ); +} + +it('validates a well formed RS256 id token', function () { + $keyset = oidc_keyset(); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + 'email' => 'User@Example.com', + ], $keyset['private_pem']); + + $claims = app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); + + expect($claims['sub'])->toBe('okta-user-1') + ->and($claims['email'])->toBe('User@Example.com'); +}); + +it('rejects invalid token claims', function (array $claimOverrides, string $message) { + $keyset = oidc_keyset(); + $now = time(); + $claims = array_merge([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + ], $claimOverrides); + + $token = oidc_token($claims, $keyset['private_pem']); + + app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); +})->throws(OidcTokenException::class)->with([ + 'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'], + 'audience mismatch' => [['aud' => 'other-client'], 'audience'], + 'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'], + 'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'], + 'expired token' => [['exp' => time() - 3600], 'expired'], + 'future issued at' => [['iat' => time() + 3600], 'issued'], + 'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'], + 'missing subject' => [['sub' => null], 'subject'], + 'empty subject' => [['sub' => ''], 'subject'], + 'non-string subject' => [['sub' => 123], 'subject'], +]); + +it('rejects a bad signature and unknown key id', function (string $kid) { + $keyset = oidc_keyset('test-key'); + $otherKeyset = oidc_keyset($kid); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + 'nonce' => 'expected-nonce', + ], $otherKeyset['private_pem'], $kid); + + app(OidcTokenValidator::class)->validate( + idToken: $token, + discovery: oidc_discovery(), + jwks: $keyset['jwks'], + clientId: 'client-id', + expectedNonce: 'expected-nonce', + ); +})->throws(OidcTokenException::class)->with([ + 'same kid with bad signature' => ['test-key'], + 'unknown kid' => ['other-key'], +]); + +it('rejects disallowed algorithms', function () { + $keyset = oidc_keyset(); + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + ], $keyset['private_pem'], algorithm: 'HS256'); + + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcTokenException::class); + +it('throws a dedicated exception when the signing key is unknown', function () { + $keyset = oidc_keyset('current-key'); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => time(), + 'exp' => time() + 600, + ], $keyset['private_pem'], 'rotated-key'); + + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcSigningKeyNotFoundException::class); + +it('rejects a jwks key not designated for signing', function () { + $keyset = oidc_keyset(); + $keyset['jwks']['keys'][0]['use'] = 'enc'; + $now = time(); + $token = oidc_token([ + 'iss' => 'https://idp.example.com', + 'aud' => 'client-id', + 'sub' => 'okta-user-1', + 'iat' => $now, + 'exp' => $now + 600, + ], $keyset['private_pem']); + + // An encryption-only key is dropped from the keyset, so the kid no longer resolves. + app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id'); +})->throws(OidcTokenException::class); diff --git a/tests/Unit/SshMultiplexingDisableTest.php b/tests/Unit/SshMultiplexingDisableTest.php index d2d4ae600f..4dedc7a768 100644 --- a/tests/Unit/SshMultiplexingDisableTest.php +++ b/tests/Unit/SshMultiplexingDisableTest.php @@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase ); } + public function test_remote_shell_prefers_bash_and_falls_back_to_sh() + { + $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand'); + + $this->assertSame( + 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi', + $reflection->invoke(null) + ); + } + public function test_generate_ssh_command_accepts_disable_multiplexing_parameter() { $reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand'); diff --git a/tests/v4/Feature/DangerDeleteResourceTest.php b/tests/v4/Feature/DangerDeleteResourceTest.php index 7a73f59795..4a275ad484 100644 --- a/tests/v4/Feature/DangerDeleteResourceTest.php +++ b/tests/v4/Feature/DangerDeleteResourceTest.php @@ -4,6 +4,7 @@ use App\Livewire\Project\Shared\Danger; use App\Models\Application; use App\Models\Environment; use App\Models\InstanceSettings; +use App\Models\OauthIdentity; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -18,7 +19,7 @@ use Livewire\Livewire; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0]); Queue::fake(); $this->user = User::factory()->create([ @@ -70,6 +71,21 @@ test('delete succeeds with correct password and redirects', function () { expect(Application::find($this->application->id))->toBeNull(); }); +test('delete succeeds without password for an oauth user', function () { + OauthIdentity::create([ + 'user_id' => $this->user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'oauth-user-id', + ]); + + Livewire::test(Danger::class, ['resource' => $this->application]) + ->call('delete', '') + ->assertHasNoErrors(); + + expect(Application::find($this->application->id))->toBeNull(); +}); + test('delete applies selectedActions from checkbox state', function () { $component = Livewire::test(Danger::class, ['resource' => $this->application]) ->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']); From dd90926583e21fbc872203fd1a6d24e6c4ca434d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:19:05 +0200 Subject: [PATCH 8/8] fix(ci): protect main during sync conflicts --- .github/workflows/sync-main-to-next.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml index 614175d9b4..595a21e799 100644 --- a/.github/workflows/sync-main-to-next.yml +++ b/.github/workflows/sync-main-to-next.yml @@ -45,15 +45,17 @@ jobs: exit 1 fi - existing_pr=$(gh pr list --base next --head main --state open --json url --jq '.[0].url') + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') if [ -n "$existing_pr" ]; then echo "A main to next pull request already exists: $existing_pr" else + git push --force origin origin/main:"refs/heads/$sync_branch" gh pr create \ --base next \ - --head main \ + --head "$sync_branch" \ --title 'chore: merge main into next' \ - --body 'This pull request was created automatically because main could not be merged into next without conflicts.' + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' fi echo 'main could not be merged into next without conflicts.'