From 593e0dbb5d47a7f845e6ea86fa24bfe5bc838d35 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:42:15 +0100 Subject: [PATCH 001/118] Remove source path from volume creation, show only when set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove host_path input from volume creation modal (users should use Directory Mount for bind mounts instead) - Conditionally display Source Path field only when host_path has a value - Add "Remove" button with confirmation modal to clear existing source paths - Add clearHostPath() method to handle source path removal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Project/Shared/Storages/Show.php | 9 ++++ .../project/service/storage.blade.php | 16 -------- .../project/shared/storages/show.blade.php | 41 ++++++++++++------- 3 files changed, 35 insertions(+), 31 deletions(-) diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 2091eca14a..e904761955 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -88,4 +88,13 @@ class Show extends Component $this->storage->delete(); $this->dispatch('refreshStorages'); } + + public function clearHostPath() + { + $this->authorize('update', $this->resource); + $this->hostPath = null; + $this->storage->host_path = null; + $this->storage->save(); + $this->dispatch('success', 'Source path removed. Use Directory Mount for host directory bindings.'); + } } diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 9e32cd22da..a33d9b0057 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -118,25 +118,9 @@
Docker Volumes mounted to 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/storages/show.blade.php b/resources/views/livewire/project/shared/storages/show.blade.php index 694f7d4f29..7b7e58dc5b 100644 --- a/resources/views/livewire/project/shared/storages/show.blade.php +++ b/resources/views/livewire/project/shared/storages/show.blade.php @@ -17,24 +17,20 @@ @endif - @if ($isService || $startedAt) + @if ($hostPath) - - @else - - @endif +
@else
- + @if ($hostPath) + + @endif
@endif @@ -43,14 +39,18 @@ @if ($isFirst)
- + @if ($hostPath) + + @endif
@else
- + @if ($hostPath) + + @endif
@endif @@ -58,6 +58,13 @@ Update + @if ($hostPath) + + @endif - + @if ($hostPath) + + @endif @else
- + @if ($hostPath) + + @endif
@endif From 687d0f88a3e446c5d39697f281181ef0ca11bfb3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:07:08 +0100 Subject: [PATCH 002/118] fix: Adjust dropdown transition margin for improved UI layout --- resources/views/livewire/project/service/storage.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index a33d9b0057..67a2fda815 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -41,7 +41,7 @@
From de214d0bf7124f888bf8b8615bb9915ebc479041 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:09:38 +0100 Subject: [PATCH 003/118] Add auto-generated default volume name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fill volume name with -data (slugified) when creating a new volume mount, improving UX by providing a sensible default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/Livewire/Project/Service/Storage.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php index 12d8bcbc32..0a309361be 100644 --- a/app/Livewire/Project/Service/Storage.php +++ b/app/Livewire/Project/Service/Storage.php @@ -56,6 +56,7 @@ class Storage extends Component } $this->refreshStorages(); + $this->name = $this->generateDefaultVolumeName(); } public function refreshStoragesFromEvent() @@ -202,7 +203,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 = ''; @@ -216,6 +217,14 @@ class Storage extends Component } } + private function generateDefaultVolumeName(): string + { + return str($this->resource->name ?? 'volume') + ->slug() + ->append('-data') + ->value(); + } + public function render() { return view('livewire.project.service.storage'); From 520b4547fc785582254fb34584c6f22ab8c15ab7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:10:01 +0100 Subject: [PATCH 004/118] Add Monaco editor to file content textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- resources/views/livewire/project/service/storage.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/project/service/storage.blade.php b/resources/views/livewire/project/service/storage.blade.php index 67a2fda815..5e19adce40 100644 --- a/resources/views/livewire/project/service/storage.blade.php +++ b/resources/views/livewire/project/service/storage.blade.php @@ -182,7 +182,7 @@ label="Destination Path" required helper="File location inside the container" /> + id="file_storage_content" useMonacoEditor> Add From 2ff0233f478affa378c53887a7c64b57c81eef8a Mon Sep 17 00:00:00 2001 From: Selim Salihovic Date: Tue, 31 Mar 2026 12:05:37 +0200 Subject: [PATCH 005/118] feat: add configurable Horizon dashboard admin access Add HORIZON_ALLOWED_EMAILS env var to grant additional users access to the Horizon dashboard. Root user (User ID 0) always retains access. Co-Authored-By: Claude Sonnet 4.5 --- .env.development.example | 5 +++++ app/Providers/HorizonServiceProvider.php | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.env.development.example b/.env.development.example index 594b89201e..8537a9b99a 100644 --- a/.env.development.example +++ b/.env.development.example @@ -24,6 +24,11 @@ RAY_ENABLED=false # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false +# Laravel Horizon Admin Access +# Comma-separated list of email addresses allowed to access /horizon dashboard +# The root user (User ID 0) always has access +# HORIZON_ALLOWED_EMAILS=admin@example.com,devops@example.com + # Enable Laravel Nightwatch monitoring NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 0caa3a3a93..037636496b 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -55,9 +55,18 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider Gate::define('viewHorizon', function ($user) { $root_user = User::find(0); - return in_array($user->email, [ - $root_user->email, - ]); + // Get additional allowed emails from environment variable + $allowedEmails = array_filter( + array_map('trim', explode(',', env('HORIZON_ALLOWED_EMAILS', ''))) + ); + + // Merge root user email with additional allowed emails + $authorizedEmails = array_merge( + [$root_user->email], + $allowedEmails + ); + + return in_array($user->email, $authorizedEmails); }); } } From e354840e5ae21c4d9f108858f623a3dc0d26818f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:59:08 +0200 Subject: [PATCH 006/118] feat(auth): add OIDC SSO and registration controls Add OIDC discovery, JWKS validation, Socialite integration, OAuth identity linking, and configurable registration policy for password and SSO signups. Update related settings/profile UI and tests. --- app/Actions/Fortify/CreateNewUser.php | 2 +- .../Exceptions/OidcDiscoveryException.php | 5 + app/Auth/Oidc/Exceptions/OidcException.php | 7 + .../Oidc/Exceptions/OidcJwksException.php | 5 + .../Oidc/Exceptions/OidcTokenException.php | 5 + app/Auth/Oidc/OidcConfig.php | 34 ++ app/Auth/Oidc/OidcDiscoveryDocument.php | 61 ++++ app/Auth/Oidc/OidcDiscoveryService.php | 73 ++++ app/Auth/Oidc/OidcTokenValidator.php | 142 ++++++++ app/Auth/Oidc/OidcUser.php | 32 ++ app/Auth/Oidc/RsaJwk.php | 112 +++++++ app/Auth/Oidc/Socialite/OidcProvider.php | 221 +++++++++++++ app/Http/Controllers/OauthController.php | 61 ++-- app/Livewire/Notifications/Discord.php | 24 ++ app/Livewire/Notifications/Email.php | 116 +++++-- 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 | 48 ++- app/Livewire/Server/LogDrains.php | 62 ++++ app/Livewire/Settings/Advanced.php | 6 + app/Livewire/SettingsEmail.php | 114 +++++-- app/Livewire/SettingsOauth.php | 313 +++++++++++++----- app/Models/InstanceSettings.php | 16 + app/Models/OauthIdentity.php | 35 ++ app/Models/OauthSetting.php | 50 ++- app/Models/User.php | 11 + app/Providers/AppServiceProvider.php | 24 +- app/Providers/FortifyServiceProvider.php | 11 +- app/Services/Auth/OauthLoginService.php | 141 ++++++++ bootstrap/helpers/socialite.php | 28 +- config/services.php | 8 + ...ation_deployment_configuration_columns.php | 8 + ...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 ++ database/seeders/OauthSettingSeeder.php | 1 + lang/de.json | 1 + lang/en.json | 1 + lang/pl.json | 1 + resources/views/auth/login.blade.php | 2 +- .../components/settings/navbar.blade.php | 8 - .../components/settings/sidebar.blade.php | 4 + .../livewire/notifications/discord.blade.php | 8 +- .../livewire/notifications/email.blade.php | 30 +- .../livewire/notifications/pushover.blade.php | 10 +- .../livewire/notifications/slack.blade.php | 10 +- .../livewire/notifications/telegram.blade.php | 10 +- .../livewire/notifications/webhook.blade.php | 12 +- .../views/livewire/profile/index.blade.php | 17 +- .../livewire/server/log-drains.blade.php | 80 +++-- .../views/livewire/settings-backup.blade.php | 11 +- .../views/livewire/settings-email.blade.php | 52 +-- .../views/livewire/settings-oauth.blade.php | 170 +++++++--- .../livewire/settings/advanced.blade.php | 5 + routes/web.php | 3 + tests/Feature/EnableActionButtonsTest.php | 179 ++++++++++ tests/Feature/OauthControllerTest.php | 6 +- tests/Feature/OauthRegistrationPolicyTest.php | 52 +++ tests/Feature/OidcOauthControllerTest.php | 146 ++++++++ tests/Feature/ProfileSsoIndicatorTest.php | 91 +++++ tests/Feature/SettingsNavigationTest.php | 52 +++ tests/Feature/SettingsOauthTest.php | 229 +++++++++++++ tests/Unit/OauthSettingTest.php | 30 ++ tests/Unit/OidcDiscoveryServiceTest.php | 57 ++++ tests/Unit/OidcTokenValidatorTest.php | 153 +++++++++ 67 files changed, 3082 insertions(+), 334 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/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/RsaJwk.php create mode 100644 app/Auth/Oidc/Socialite/OidcProvider.php create mode 100644 app/Models/OauthIdentity.php create mode 100644 app/Services/Auth/OauthLoginService.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 tests/Feature/EnableActionButtonsTest.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/SettingsNavigationTest.php create mode 100644 tests/Feature/SettingsOauthTest.php create mode 100644 tests/Unit/OauthSettingTest.php create mode 100644 tests/Unit/OidcDiscoveryServiceTest.php create mode 100644 tests/Unit/OidcTokenValidatorTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index cddf663897..c186c80429 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -20,7 +20,7 @@ class CreateNewUser implements CreatesNewUsers public function create(array $input): User { $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { abort(403); } Validator::make($input, [ 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..0d35ff45cb --- /dev/null +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -0,0 +1,73 @@ +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.'); + } + + return $json; + }); + + $discovery = OidcDiscoveryDocument::fromArray($payload); + if (rtrim($discovery->issuer, '/') !== $issuerUrl) { + throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); + } + + return $discovery; + } + + /** + * @return array + */ + public function jwks(string $jwksUri): array + { + $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); + + 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; + }); + } +} diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php new file mode 100644 index 0000000000..98dc6d4c00 --- /dev/null +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -0,0 +1,142 @@ + $jwks + * @return array + */ + public function validate( + string $idToken, + OidcDiscoveryDocument $discovery, + array $jwks, + string $clientId, + ?string $expectedNonce = null, + int $clockSkewSeconds = 60, + ): array { + [$header, $claims, $signatureInput, $signature] = $this->parse($idToken); + + $algorithm = $header['alg'] ?? null; + if ($algorithm !== 'RS256') { + 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.'); + } + + $jwk = $this->findJwk($jwks, $kid); + $publicKey = RsaJwk::toPem($jwk); + + if (openssl_verify($signatureInput, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { + throw new OidcTokenException('id_token signature is invalid.'); + } + + $this->assertIssuer($claims, $discovery->issuer); + $this->assertAudience($claims, $clientId); + $this->assertTimestamps($claims, $clockSkewSeconds); + $this->assertNonce($claims, $expectedNonce); + + return $claims; + } + + /** + * @return array{0: array, 1: array, 2: string, 3: string} + */ + private function parse(string $idToken): array + { + $segments = explode('.', $idToken); + if (count($segments) !== 3) { + throw new OidcTokenException('Malformed id_token.'); + } + + $header = json_decode(RsaJwk::base64UrlDecode($segments[0]), true); + $claims = json_decode(RsaJwk::base64UrlDecode($segments[1]), true); + + if (! is_array($header) || ! is_array($claims)) { + throw new OidcTokenException('id_token contains invalid JSON.'); + } + + return [$header, $claims, $segments[0].'.'.$segments[1], RsaJwk::base64UrlDecode($segments[2])]; + } + + /** + * @param array $jwks + * @return array + */ + private function findJwk(array $jwks, string $kid): array + { + foreach ($jwks['keys'] ?? [] as $jwk) { + if (is_array($jwk) && ($jwk['kid'] ?? null) === $kid) { + return $jwk; + } + } + + throw new OidcTokenException('No matching JWKS key found for id_token kid.'); + } + + /** + * @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 (isset($claims['azp']) && $claims['azp'] !== $clientId) { + throw new OidcTokenException('id_token azp does not match configured client id.'); + } + } + + /** + * @param array $claims + */ + private function assertTimestamps(array $claims, int $clockSkewSeconds): void + { + $now = time(); + $expiration = $claims['exp'] ?? null; + if (! is_int($expiration) || ($expiration + $clockSkewSeconds) < $now) { + throw new OidcTokenException('id_token is expired.'); + } + + $issuedAt = $claims['iat'] ?? null; + if (! is_int($issuedAt) || ($issuedAt - $clockSkewSeconds) > $now) { + throw new OidcTokenException('id_token issued at time is invalid.'); + } + } + + /** + * @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/RsaJwk.php b/app/Auth/Oidc/RsaJwk.php new file mode 100644 index 0000000000..3faef77b2c --- /dev/null +++ b/app/Auth/Oidc/RsaJwk.php @@ -0,0 +1,112 @@ + $jwk + */ + public static function toPem(array $jwk): string + { + if (($jwk['kty'] ?? null) !== 'RSA' || ! is_string($jwk['n'] ?? null) || ! is_string($jwk['e'] ?? null)) { + throw new OidcTokenException('JWKS key is not a valid RSA signing key.'); + } + + $modulus = self::base64UrlDecode($jwk['n']); + $exponent = self::base64UrlDecode($jwk['e']); + + $sequence = self::encodeSequence( + self::encodeInteger($modulus). + self::encodeInteger($exponent) + ); + + $bitString = self::encodeBitString($sequence); + $algorithmIdentifier = self::encodeSequence( + self::encodeObjectIdentifier('1.2.840.113549.1.1.1'). + self::encodeNull() + ); + + $subjectPublicKeyInfo = self::encodeSequence($algorithmIdentifier.$bitString); + + return "-----BEGIN PUBLIC KEY-----\n". + chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n"). + "-----END PUBLIC KEY-----\n"; + } + + public static 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.'); + } + + return $decoded; + } + + private static function encodeLength(int $length): string + { + if ($length < 128) { + return chr($length); + } + + $encoded = ltrim(pack('N', $length), "\x00"); + + return chr(0x80 | strlen($encoded)).$encoded; + } + + private static function encodeInteger(string $value): string + { + $value = ltrim($value, "\x00"); + if ($value === '') { + $value = "\x00"; + } + if ((ord($value[0]) & 0x80) !== 0) { + $value = "\x00".$value; + } + + return "\x02".self::encodeLength(strlen($value)).$value; + } + + private static function encodeSequence(string $value): string + { + return "\x30".self::encodeLength(strlen($value)).$value; + } + + private static function encodeBitString(string $value): string + { + $value = "\x00".$value; + + return "\x03".self::encodeLength(strlen($value)).$value; + } + + private static function encodeNull(): string + { + return "\x05\x00"; + } + + private static function encodeObjectIdentifier(string $oid): string + { + $parts = array_map('intval', explode('.', $oid)); + $encoded = chr((40 * $parts[0]) + $parts[1]); + + foreach (array_slice($parts, 2) as $part) { + $stack = [chr($part & 0x7F)]; + $part >>= 7; + while ($part > 0) { + array_unshift($stack, chr(($part & 0x7F) | 0x80)); + $part >>= 7; + } + $encoded .= implode('', $stack); + } + + return "\x06".self::encodeLength(strlen($encoded)).$encoded; + } +} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php new file mode 100644 index 0000000000..65a476d45d --- /dev/null +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -0,0 +1,221 @@ + + */ + 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->request->session()->put($this->nonceSessionKey(), $nonce); + + $extra = ['nonce' => $nonce]; + if ($config->usePkce) { + $verifier = $this->generateCodeVerifier(); + $this->request->session()->put($this->verifierSessionKey(), $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, + ], + ]); + + $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(); + $claims = $this->tokenValidator->validate( + idToken: $idToken, + discovery: $discovery, + jwks: $this->discoveryService->jwks($discovery->jwksUri), + clientId: $config->clientId, + expectedNonce: $this->request->session()->pull($this->nonceSessionKey()), + clockSkewSeconds: $config->clockSkewSeconds, + ); + + $userinfo = $this->getUserByToken($accessToken); + $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; + } + + /** + * @return array + */ + public function getAccessTokenResponse($code) + { + $fields = $this->getTokenFields($code); + if ($this->getConfig()->usePkce) { + $verifier = $this->request->session()->pull($this->verifierSessionKey()); + if (is_string($verifier) && $verifier !== '') { + $fields['code_verifier'] = $verifier; + } + } + + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::HEADERS => ['Accept' => 'application/json'], + RequestOptions::FORM_PARAMS => $fields, + ]); + + $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 nonceSessionKey(): string + { + return 'oidc.nonce'; + } + + protected function verifierSessionKey(): string + { + return 'oidc.code_verifier'; + } +} 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 ab3884320b..845c25a540 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -163,6 +163,30 @@ class Discord extends Component } } + public function toggleDiscordEnabled() + { + 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(); + + return handleError($e, $this); + } + } + public function instantSave() { try { diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 724dd0bac1..88160cedd8 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -240,29 +240,57 @@ 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() { 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', - ], [ - '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; @@ -291,17 +319,7 @@ class Email extends Component { try { $this->resetErrorBag(); - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => 'required|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; } @@ -318,6 +336,44 @@ 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', + ], [ + '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' => 'required|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 d79eea87be..88d80126cc 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -153,6 +153,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 f870b39868..7fee4e32e6 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -147,6 +147,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 fc3966cf6c..ebb49e0a93 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -246,6 +246,34 @@ class Telegram extends Component } } + public function toggleTelegramEnabled() + { + 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(); + + return 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 630d422a9b..1f79eb004e 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -141,6 +141,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 4a419a12f3..de7c268072 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -32,14 +32,22 @@ class Index extends Component public bool $show_verification = false; + public bool $uses_sso = false; + + public ?string $sso_provider_label = null; + public function mount() { $this->userId = Auth::id(); $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; } @@ -64,6 +72,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(); @@ -122,6 +134,10 @@ class Index extends Component public function verifyEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -178,6 +194,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.'); @@ -233,10 +253,28 @@ class Index extends Component 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 { @@ -267,6 +305,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/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 5d77f4998e..18d07ea905 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,6 +177,39 @@ class LogDrains extends Component } } + public function toggleLogDrain(string $type) + { + 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) { + $this->syncData(); + + return handleError($e, $this); + } + } + public function submit(string $type) { try { @@ -192,4 +225,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 3a62371839..8b8e93f5fa 100644 --- a/app/Livewire/Settings/Advanced.php +++ b/app/Livewire/Settings/Advanced.php @@ -15,6 +15,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; @@ -44,6 +47,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], @@ -66,6 +70,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; @@ -147,6 +152,7 @@ class Advanced extends Component { try { $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 8c0e244001..9e00936059 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -138,28 +138,54 @@ class SettingsEmail extends Component } } + 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->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', - ], [ - '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(); $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; @@ -184,17 +210,7 @@ class SettingsEmail extends Component public function submitResend() { try { - $this->validate([ - 'resendEnabled' => 'boolean', - 'resendApiKey' => 'required|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(); $this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_api_key = $this->resendApiKey; @@ -211,6 +227,44 @@ 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', + ], [ + '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' => 'required|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 6f949b7167..24eddfaf9b 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -9,43 +9,68 @@ class SettingsOauth extends Component { 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.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) { if (! isInstanceAdmin()) { return redirect()->route('home'); } + + $this->selectedProvider = $provider; + $this->disable_registration_when_oauth_enabled = (bool) instanceSettings()->disable_registration_when_oauth_enabled; $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, - ]; + $carry[$setting->provider] = $this->oauthSettingToArray($setting); return $carry; }, []); + + if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { + abort(404); + } } - 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']); @@ -54,78 +79,126 @@ 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 (! $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), + '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, + '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) @@ -137,9 +210,71 @@ class SettingsOauth extends Component } } - public function submit() + public function toggleProvider(string $provider) { - $this->updateOauthSettings(); - $this->dispatch('success', 'Instance settings updated successfully!'); + try { + if (! array_key_exists($provider, $this->oauth_settings_map)) { + abort(404); + } + + if (! (bool) $this->oauth_settings_map[$provider]['enabled']) { + $this->validateProviderCanBeEnabled($provider); + } + + $this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled']; + $this->updateOauthSettings($provider); + } catch (\Exception $e) { + $oauth = OauthSetting::where('provider', $provider)->first(); + if ($oauth) { + $this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth); + } + + return handleError($e, $this); + } + } + + private function validateProviderCanBeEnabled(string $provider): void + { + $this->validate($this->validationRules($provider)); + + $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.'); + } + + $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 ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) { + throw new \Exception("OIDC scopes must include 'openid'."); + } + } + + public function saveRegistrationPolicy(): void + { + $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 + { + $this->updateOauthSettings($this->selectedProvider); + + if ($this->selectedProvider === null) { + $this->dispatch('success', 'Instance settings updated successfully!'); + } } } diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index d5c3bfa28c..65fcd14866 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -18,6 +18,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', @@ -64,6 +65,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', @@ -84,6 +87,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/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 08e08d85b1..25e38b4d1c 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,7 +11,18 @@ 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', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; + + protected function casts(): array + { + return [ + 'enabled' => 'boolean', + 'allow_registration' => 'boolean', + 'require_email_verified' => 'boolean', + 'use_pkce' => 'boolean', + 'clock_skew_seconds' => 'integer', + ]; + } protected function clientSecret(): Attribute { @@ -28,9 +39,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/User.php b/app/Models/User.php index 9cbe888352..5fb65f4f35 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; @@ -499,6 +500,16 @@ 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. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 717daf2a2c..9858289bea 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 Laravel\Telescope\TelescopeServiceProvider; class AppServiceProvider extends ServiceProvider @@ -28,7 +32,7 @@ class AppServiceProvider extends ServiceProvider $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -63,6 +67,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) { diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 85f38b9670..d65be87eba 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; +use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -47,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'); } @@ -60,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, ]); }); @@ -82,7 +83,7 @@ class FortifyServiceProvider extends ServiceProvider $user->save(); // Check if user has a pending invitation they haven't accepted yet - $invitation = \App\Models\TeamInvitation::whereEmail($email)->first(); + $invitation = TeamInvitation::whereEmail($email)->first(); if ($invitation && $invitation->isValid()) { // User is logging in for the first time after being invited // Attach them to the invited team if not already attached diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php new file mode 100644 index 0000000000..feb36211c0 --- /dev/null +++ b/app/Services/Auth/OauthLoginService.php @@ -0,0 +1,141 @@ +email)); + if ($email === '') { + throw new HttpException(403, 'OAuth provider did not return an 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 + { + $user = User::whereEmail($email)->first(); + if ($user) { + return $user; + } + + if (! $this->canCreateUser($oauthSetting)) { + throw new HttpException(403, 'Registration is disabled'); + } + + return $this->createUser($oauthUser->name ?: $email, $email); + } + + 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'); + } + + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject) { + $identity = OauthIdentity::where([ + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + ])->first(); + + if ($identity) { + $identity->update([ + 'email' => $email, + 'raw_claims' => $oauthUser->user, + '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); + } + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => $issuer, + 'provider_user_id' => $subject, + 'email' => $email, + 'raw_claims' => $oauthUser->user, + 'last_login_at' => now(), + ]); + + return $user; + }); + } + + private function canCreateUser(OauthSetting $oauthSetting): bool + { + return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; + } + + private function createUser(string $name, string $email): 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; + } + + return User::create([ + 'name' => $name, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + ]); + } +} 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/config/services.php b/config/services.php index 6a21cda184..6ce6290ce6 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 123fd226dd..1a620c78eb 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 @@ -11,12 +11,20 @@ return new class extends Migration */ public function up(): void { + if (DB::connection()->getDriverName() === 'sqlite') { + return; + } + DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text'); } public function down(): void { + if (DB::connection()->getDriverName() === 'sqlite') { + return; + } + DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json'); } 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/seeders/OauthSettingSeeder.php b/database/seeders/OauthSettingSeeder.php index 2e5e6fcc4c..3edf40bdeb 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -22,6 +22,7 @@ class OauthSettingSeeder extends Seeder 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/lang/de.json b/lang/de.json index fd587de22f..9193a05e42 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 a81e1ee682..3e587763d1 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/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index ede49117ae..d598341458 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -90,7 +90,7 @@ @foreach ($enabled_oauth_providers as $provider_setting) - {{ __("auth.login.$provider_setting->provider") }} + {{ $provider_setting->loginLabel() }} @endforeach
diff --git a/resources/views/components/settings/navbar.blade.php b/resources/views/components/settings/navbar.blade.php index 565e485d09..6e6ed5d207 100644 --- a/resources/views/components/settings/navbar.blade.php +++ b/resources/views/components/settings/navbar.blade.php @@ -7,14 +7,6 @@ href="{{ route('settings.index') }}"> Configuration - - Backup - - - Transactional Email - OAuth diff --git a/resources/views/components/settings/sidebar.blade.php b/resources/views/components/settings/sidebar.blade.php index 0a68a91558..8308fbc443 100644 --- a/resources/views/components/settings/sidebar.blade.php +++ b/resources/views/components/settings/sidebar.blade.php @@ -3,6 +3,10 @@ href="{{ route('settings.index') }}">General Advanced + Instance Backup + Transactional Email Updates
diff --git a/resources/views/livewire/notifications/discord.blade.php b/resources/views/livewire/notifications/discord.blade.php index 0e5406c78e..f665a87fac 100644 --- a/resources/views/livewire/notifications/discord.blade.php +++ b/resources/views/livewire/notifications/discord.blade.php @@ -10,18 +10,20 @@ Save @if ($discordEnabled) + + Disable Discord + Send Test Notification @else - - Send Test Notification + + Enable Discord @endif
- diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index 71a9f0680b..e105f5fe4e 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -21,10 +21,6 @@ - @else - - Send Test Email - @endif @endcan @endif @@ -63,10 +59,15 @@ Save -
-
- + @if ($smtpEnabled) + + Disable SMTP Server + + @else + + Enable SMTP Server + + @endif
@@ -95,10 +96,15 @@ Save -
-
- + @if ($resendEnabled) + + Disable Resend + + @else + + Enable Resend + + @endif
diff --git a/resources/views/livewire/notifications/pushover.blade.php b/resources/views/livewire/notifications/pushover.blade.php index 74cd9e8d24..f633b7b33a 100644 --- a/resources/views/livewire/notifications/pushover.blade.php +++ b/resources/views/livewire/notifications/pushover.blade.php @@ -10,19 +10,19 @@ Save @if ($pushoverEnabled) + + Disable Pushover + Send Test Notification @else - - Send Test Notification + + Enable Pushover @endif
-
- -
@if ($slackEnabled) + + Disable Slack + Send Test Notification @else - - Send Test Notification + + Enable Slack @endif
-
- -
diff --git a/resources/views/livewire/notifications/telegram.blade.php b/resources/views/livewire/notifications/telegram.blade.php index f87a13c37d..fb7b1bc40a 100644 --- a/resources/views/livewire/notifications/telegram.blade.php +++ b/resources/views/livewire/notifications/telegram.blade.php @@ -10,19 +10,19 @@ Save @if ($telegramEnabled) + + Disable Telegram + Send Test Notification @else - - Send Test Notification + + Enable Telegram @endif
-
- -
@if ($webhookEnabled) + + Disable Webhook + Send Test Notification @else - - Send Test Notification + + Enable Webhook @endif
-
- -
- @if (!$show_email_change && !$show_verification) + @if ($uses_sso) + Change Email + @elseif (!$show_email_change && !$show_verification) Change Email @else Change Email @endif
+ @if ($uses_sso) +
+ Signed in with SSO + @if ($sso_provider_label) + ({{ $sso_provider_label }}) + @endif + Email is managed by your SSO provider. +
+ @endif
- @if ($show_email_change) + @if (! $uses_sso && $show_email_change)
@@ -34,7 +45,7 @@ @endif - @if ($show_verification) + @if (! $uses_sso && $show_verification)
isFunctional())

Log Drains

- +
Sends service logs to 3rd party tools.
-

New Relic

-
- @if ($isLogDrainAxiomEnabled || $isLogDrainCustomEnabled) - +
+

New Relic

+ @if ($isLogDrainNewRelicEnabled) + + Save + + + Disable New Relic + + @elseif ($isLogDrainAxiomEnabled || $isLogDrainCustomEnabled) + + Enable New Relic + @else - + + Enable New Relic + @endif
@@ -51,16 +61,26 @@
-

Axiom

-
- @if ($isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled) - - @else - - @endif -
+
+

Axiom

+ @if ($isLogDrainAxiomEnabled) + + Save + + + Disable Axiom + + @elseif ($isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled) + + Enable Axiom + + @else + + Enable Axiom + + @endif +
@if ($server->isLogDrainEnabled()) @@ -82,16 +102,26 @@
-

Custom FluentBit

-
- @if ($isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled) - - @else - - @endif -
+
+

Custom FluentBit

+ @if ($isLogDrainCustomEnabled) + + Save + + + Disable Custom FluentBit + + @elseif ($isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled) + + Enable Custom FluentBit + + @else + + Enable Custom FluentBit + + @endif +
@if ($server->isLogDrainEnabled()) -
-
-

Backup

+
+ +
+
+

Instance Backup

@if (isset($database) && $server->isFunctional()) Save @endif
-
Backup configuration for Coolify instance.
+
Instance backup configuration for Coolify instance.
@if ($server->isFunctional()) @if (isset($database) && isset($backup)) diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index 93abd628c3..eb9cb1b6b0 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -3,7 +3,11 @@ Transactional Email | Coolify - +
+ +
+

Transactional Email

@@ -27,17 +31,22 @@
-
-
+
-
+

SMTP Server

- - Save - -
-
- + @if ($smtpEnabled) + + Save + + + Disable SMTP Server + + @else + + Enable SMTP Server + + @endif
@@ -57,17 +66,21 @@
-
-
-
+

Resend

- - Save - -
-
- + @if ($resendEnabled) + + Save + + + Disable Resend + + @else + + Enable Resend + + @endif
@@ -76,6 +89,7 @@
+
diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 7650a56541..82ad9d5810 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -3,51 +3,131 @@ Settings | Coolify -
-
-
-

Authentication

- - Save - -
-
Custom authentication (OAuth) configurations.
-
-
- @foreach ($oauth_settings_map as $oauth_setting) -
-

{{ ucfirst($oauth_setting['provider']) }}

-
- -
-
- - - - @if ($oauth_setting['provider'] == 'azure') - - @endif - @if ($oauth_setting['provider'] == 'google') - - @endif - @if ( - $oauth_setting['provider'] == 'authentik' || - $oauth_setting['provider'] == 'clerk' || - $oauth_setting['provider'] == 'zitadel' || - $oauth_setting['provider'] == 'gitlab') - - @endif -
-
+ +
+ - + +
+ @if ($selectedProvider === null) +
+
+

Authentication

+
+
General authentication settings for your Coolify instance.
+
+
+
+
+

Registration

+ +
+
+ +
+
+
+ @else + @php + $oauth_setting = $oauth_settings_map[$selectedProvider] ?? null; + @endphp + + @if ($oauth_setting) +
+
+

{{ $oauth_setting['label'] }}

+ @if ($oauth_setting['enabled']) + + Save + + + Disable {{ $oauth_setting['label'] }} + + @else + + Enable {{ $oauth_setting['label'] }} + + @endif +
+
OAuth configuration for {{ $oauth_setting['label'] }}.
+
+
+
+
+ + + + @if ($oauth_setting['provider'] == 'azure') + + @endif + @if ($oauth_setting['provider'] == 'google') + + @endif + @if ( + $oauth_setting['provider'] == 'authentik' || + $oauth_setting['provider'] == 'clerk' || + $oauth_setting['provider'] == 'zitadel' || + $oauth_setting['provider'] == 'gitlab') + + @endif + @if ($oauth_setting['provider'] == 'oidc') + + @endif +
+ @if ($oauth_setting['provider'] == 'oidc') +
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+ @endif +
+
+ @endif + @endif +
+
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 544ed7d4cf..ba9c929ece 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -41,6 +41,11 @@ shortConfirmationLabel="Confirmation text" />
@endif +
+ +
group(function () { Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup'); Route::get('/settings/email', SettingsEmail::class)->name('settings.email'); Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth'); + Route::get('/settings/oauth/{provider}', SettingsOauth::class) + ->where('provider', '[A-Za-z0-9_-]+') + ->name('settings.oauth.provider'); Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs'); Route::get('/profile', ProfileIndex::class)->name('profile'); diff --git a/tests/Feature/EnableActionButtonsTest.php b/tests/Feature/EnableActionButtonsTest.php new file mode 100644 index 0000000000..01639d4979 --- /dev/null +++ b/tests/Feature/EnableActionButtonsTest.php @@ -0,0 +1,179 @@ +create(); + $user = User::factory()->create(['email' => 'owner@example.com']); + $user->teams()->attach($team, ['role' => 'owner']); + + session(['currentTeam' => $team]); + test()->actingAs($user); + + return [$user, $team]; +} + +function actingAsEnableActionInstanceAdmin(): User +{ + $team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); + $user = User::factory()->create(['id' => 0, 'email' => 'root-enable-actions@example.com']); + if (! $user->teams()->whereKey($team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + + session(['currentTeam' => $team]); + test()->actingAs($user); + + return $user; +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); +}); + +it('renders settings email enable actions instead of enabled checkboxes', function () { + $view = file_get_contents(resource_path('views/livewire/settings-email.blade.php')); + + expect($view)->toContain('Enable SMTP Server') + ->and($view)->toContain('Disable SMTP Server') + ->and($view)->toContain('Enable Resend') + ->and($view)->toContain('Disable Resend') + ->and($view)->not->toContain('id="smtpEnabled" label="Enabled"') + ->and($view)->not->toContain('id="resendEnabled" label="Enabled"'); +}); + +it('keeps transactional smtp disabled when enable validation fails', function () { + actingAsEnableActionInstanceAdmin(); + + Livewire::test(SettingsEmail::class) + ->call('toggleSmtp') + ->assertDispatched('error') + ->assertSet('smtpEnabled', false); + + expect(instanceSettings()->fresh()->smtp_enabled)->toBeFalse(); +}); + +it('enables transactional smtp only after required fields validate', function () { + actingAsEnableActionInstanceAdmin(); + + Livewire::test(SettingsEmail::class) + ->set('smtpFromAddress', 'mail@example.com') + ->set('smtpFromName', 'Coolify') + ->set('smtpHost', 'smtp.example.com') + ->set('smtpPort', '587') + ->set('smtpEncryption', 'starttls') + ->call('toggleSmtp') + ->assertHasNoErrors() + ->assertSet('smtpEnabled', true) + ->assertSet('resendEnabled', false); + + expect(instanceSettings()->fresh()->smtp_enabled)->toBeTrue() + ->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse(); +}); + +it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) { + $contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php")); + + expect($contents)->toContain($enableLabel) + ->and($contents)->not->toContain($checkboxSnippet); +})->with([ + 'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'], + 'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'], + 'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'], + 'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'], + 'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'], +]); + +it('shows notification provider save buttons while disabled', function (string $component) { + actingAsEnableActionOwner(); + + Livewire::test($component) + ->assertSet(str(class_basename($component))->camel()->append('Enabled')->toString(), false) + ->assertSee('Save'); +})->with([ + 'discord' => [Discord::class], + 'slack' => [Slack::class], + 'telegram' => [Telegram::class], + 'pushover' => [Pushover::class], + 'webhook' => [Webhook::class], +]); + +it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) { + actingAsEnableActionOwner(); + + Livewire::test($component) + ->assertDontSee('Send Test Notification'); + + Livewire::test($component) + ->set($enabledProperty, true) + ->assertSee('Send Test Notification'); +})->with([ + 'discord' => [Discord::class, 'discordEnabled'], + 'slack' => [Slack::class, 'slackEnabled'], + 'telegram' => [Telegram::class, 'telegramEnabled'], + 'pushover' => [Pushover::class, 'pushoverEnabled'], + 'webhook' => [Webhook::class, 'webhookEnabled'], +]); + +it('hides the email test button while email notifications are disabled', function () { + actingAsEnableActionOwner(); + + Livewire::test(Email::class) + ->assertDontSee('Send Test Email'); +}); + +it('keeps notification providers disabled when enable validation fails', function (string $component, string $method, string $enabledProperty, string $requiredField, string $settingsRelation, string $settingsColumn) { + [, $team] = actingAsEnableActionOwner(); + + Livewire::test($component) + ->call($method) + ->assertDispatched('error') + ->assertSet($enabledProperty, false); + + expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse(); +})->with([ + 'discord' => [Discord::class, 'toggleDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', 'discord_enabled'], + 'slack' => [Slack::class, 'toggleSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', 'slack_enabled'], + 'telegram' => [Telegram::class, 'toggleTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', 'telegram_enabled'], + 'pushover' => [Pushover::class, 'togglePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', 'pushover_enabled'], + 'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'], +]); + +it('renders notification email and log drain enable actions instead of enabled checkboxes', function () { + $notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php')); + $logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php')); + + expect($notificationEmail)->toContain('Enable SMTP Server') + ->and($notificationEmail)->toContain('Enable Resend') + ->and($notificationEmail)->not->toContain('id="smtpEnabled"') + ->and($notificationEmail)->not->toContain('id="resendEnabled"') + ->and($logDrains)->toContain('Enable New Relic') + ->and($logDrains)->toContain('Enable Axiom') + ->and($logDrains)->toContain('Enable Custom FluentBit') + ->and($logDrains)->not->toContain('label="Enabled"'); +}); + +it('keeps notification email smtp disabled when enable validation fails', function () { + actingAsEnableActionOwner(); + + Livewire::test(Email::class) + ->call('toggleSmtp') + ->assertDispatched('error') + ->assertSet('smtpEnabled', false); +}); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php index 1388e29808..4a54030bd7 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -4,22 +4,26 @@ use App\Models\InstanceSettings; use App\Models\OauthSetting; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Once; use Laravel\Socialite\Facades\Socialite; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::create([ + InstanceSettings::forceCreate([ 'id' => 0, 'is_registration_enabled' => false, ]); + Once::flush(); + OauthSetting::create([ 'provider' => 'google', 'client_id' => 'client-id', 'client_secret' => 'client-secret', 'redirect_uri' => 'https://coolify.example.com/auth/google/callback', 'tenant' => 'example.com', + 'enabled' => true, ]); }); diff --git a/tests/Feature/OauthRegistrationPolicyTest.php b/tests/Feature/OauthRegistrationPolicyTest.php new file mode 100644 index 0000000000..86186cca8c --- /dev/null +++ b/tests/Feature/OauthRegistrationPolicyTest.php @@ -0,0 +1,52 @@ + 0, + 'is_registration_enabled' => true, + 'disable_registration_when_oauth_enabled' => true, + ]); + Once::flush(); +}); + +it('blocks password registration when oauth registration policy disables it', function () { + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => true, + 'client_id' => 'client-id', + 'client_secret' => 'secret', + 'base_url' => 'https://idp.example.com', + ]); + + app(CreateNewUser::class)->create([ + 'name' => 'Password User', + 'email' => 'password@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); +})->throws(HttpException::class); + +it('allows password registration when no oauth provider is enabled', function () { + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => false, + ]); + + $user = app(CreateNewUser::class)->create([ + 'name' => 'Password User', + 'email' => 'password@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + expect($user->email)->toBe('password@example.com'); +}); diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php new file mode 100644 index 0000000000..4199eca5fa --- /dev/null +++ b/tests/Feature/OidcOauthControllerTest.php @@ -0,0 +1,146 @@ +set('app.maintenance.driver', 'file'); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_registration_enabled' => false, + ]); + + Once::flush(); + + OauthSetting::create([ + 'provider' => 'oidc', + 'enabled' => true, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'base_url' => 'https://idp.example.com', + 'redirect_uri' => 'https://coolify.example.com/auth/oidc/callback', + 'allow_registration' => false, + ]); +}); + +function fakeOidcProvider(array $claims = []): void +{ + $user = (new OidcUser)->setRaw(array_merge([ + 'iss' => 'https://idp.example.com', + 'sub' => 'okta-user-1', + 'email' => 'user@example.com', + 'email_verified' => true, + 'name' => 'Okta User', + ], $claims))->map([ + 'id' => $claims['sub'] ?? 'okta-user-1', + 'name' => $claims['name'] ?? 'Okta User', + 'email' => $claims['email'] ?? 'user@example.com', + ]); + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn($user); + + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); +} + +it('logs in a user through an existing oidc identity', function () { + $user = User::factory()->create(['email' => 'existing@example.com']); + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'okta-user-1', + 'email' => 'existing@example.com', + ]); + + fakeOidcProvider(['email' => 'existing@example.com']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $this->assertAuthenticatedAs($user); +}); + +it('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () { + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + fakeOidcProvider(['email' => 'newuser@example.com']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $user = User::whereEmail('newuser@example.com')->first(); + expect($user)->not->toBeNull() + ->and($user->password)->not->toBeNull(); + $this->assertAuthenticatedAs($user); + $this->assertDatabaseHas('oauth_identities', [ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'okta-user-1', + ]); +}); + +it('rejects new oidc users when neither normal nor provider registration is enabled', function () { + fakeOidcProvider(['email' => 'blocked@example.com']); + + $response = $this->from('/login')->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/login'); + expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse(); +}); + +it('creates the root user when oidc provisions the first account', function () { + Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']); + $this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']); +}); + +it('rejects callbacks for disabled oidc provider', function () { + OauthSetting::where('provider', 'oidc')->update(['enabled' => false]); + + $response = $this->from('/login')->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/login'); +}); + +it('logs callback failures with diagnostic context', function () { + Log::spy(); + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed')); + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); + + $response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value'])); + + $response->assertRedirect('/login'); + Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) { + return $message === 'OAuth callback failed.' + && $context['provider'] === 'oidc' + && $context['exception_class'] === RuntimeException::class + && $context['exception_message'] === 'Token exchange failed' + && $context['has_code'] === true + && $context['has_state'] === true + && $context['exception'] instanceof RuntimeException; + }); +}); diff --git a/tests/Feature/ProfileSsoIndicatorTest.php b/tests/Feature/ProfileSsoIndicatorTest.php new file mode 100644 index 0000000000..0d48225eb5 --- /dev/null +++ b/tests/Feature/ProfileSsoIndicatorTest.php @@ -0,0 +1,91 @@ +create(['name' => 'Profile User']); + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'idp-user-1', + 'email' => $user->email, + ]); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->assertSee('Signed in with SSO') + ->assertSee('OIDC'); +}); + +it('does not show sso status for password-only profile users', function () { + $user = User::factory()->create(['name' => 'Profile User']); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->assertDontSee('Signed in with SSO'); +}); + +it('prevents sso linked users from opening or requesting profile email changes', function () { + $user = User::factory()->create(['name' => 'SSO User', 'email' => 'sso@example.com']); + + OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => 'oidc', + 'issuer' => 'https://idp.example.com', + 'provider_user_id' => 'idp-user-1', + 'email' => $user->email, + ]); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->assertSee('Email is managed by your SSO provider.') + ->call('showEmailChangeForm') + ->assertSet('show_email_change', false) + ->assertDispatched('error') + ->set('new_email', 'changed@example.com') + ->call('requestEmailChange') + ->assertSet('show_email_change', false) + ->assertSet('show_verification', false) + ->assertDispatched('error'); + + $user->refresh(); + + expect($user->email)->toBe('sso@example.com') + ->and($user->pending_email)->toBeNull() + ->and($user->email_change_code)->toBeNull() + ->and($user->email_change_code_expires_at)->toBeNull(); +}); + +it('keeps profile email changes available for password-only users', function () { + config()->set('constants.coolify.self_hosted', false); + Notification::fake(); + + $user = User::factory()->create(['name' => 'Password User', 'email' => 'password@example.com']); + + $this->actingAs($user); + + Livewire::test(ProfileIndex::class) + ->call('showEmailChangeForm') + ->assertSet('show_email_change', true) + ->set('new_email', 'changed@example.com') + ->call('requestEmailChange') + ->assertSet('show_verification', true) + ->assertDispatched('success'); + + $user->refresh(); + + expect($user->pending_email)->toBe('changed@example.com') + ->and($user->email_change_code)->not->toBeNull(); +}); diff --git a/tests/Feature/SettingsNavigationTest.php b/tests/Feature/SettingsNavigationTest.php new file mode 100644 index 0000000000..96b01d8d98 --- /dev/null +++ b/tests/Feature/SettingsNavigationTest.php @@ -0,0 +1,52 @@ +blade('') + ->assertSeeText('Configuration') + ->assertSeeText('OAuth') + ->assertSeeText('Scheduled Jobs') + ->assertDontSeeText('Instance Backup') + ->assertDontSeeText('Transactional Email'); +}); + +it('shows backup and transactional email in the settings configuration sidebar', function () { + $view = $this->blade('') + ->assertSeeTextInOrder([ + 'General', + 'Advanced', + 'Instance Backup', + 'Transactional Email', + 'Updates', + ]); + + expect((string) $view) + ->toContain(route('settings.backup')) + ->toContain(route('settings.email')) + ->and(substr_count((string) $view, 'menu-item-active'))->toBe(1); +}); + +it('renders backup and transactional email pages with the settings configuration sidebar', function () { + expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) + ->toContain('') + ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php'))) + ->toContain(''); +}); + +it('uses the same title and description spacing on backup and transactional email settings pages', function () { + expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) + ->not->toContain('class="flex items-center gap-2 pb-2"') + ->toContain('
Instance backup configuration for Coolify instance.
') + ->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php'))) + ->not->toContain('class="flex flex-col gap-2 pb-4"') + ->toContain('
Instance wide email settings for password resets, invitations, etc.
'); +}); + +it('uses instance backup as the backup settings label', function () { + expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php'))) + ->toContain('Instance Backup') + ->not->toContain('Backup') + ->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php'))) + ->toContain('

Instance Backup

') + ->toContain('Instance backup configuration for Coolify instance.') + ->not->toContain('

Backup

'); +}); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php new file mode 100644 index 0000000000..e547cde983 --- /dev/null +++ b/tests/Feature/SettingsOauthTest.php @@ -0,0 +1,229 @@ + 0, 'name' => 'Root Team', 'personal_team' => true]); + $user = User::factory()->create(['id' => 0, 'email' => 'root@example.com', 'email_verified_at' => now()]); + if (! $user->teams()->whereKey($team->id)->exists()) { + $user->teams()->attach($team, ['role' => 'owner']); + } + session(['currentTeam' => $team]); + test()->actingAs($user); + + return $user; +} + +beforeEach(function () { + InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]); + Once::flush(); + OauthSetting::create(['provider' => 'oidc']); + OauthSetting::create(['provider' => 'authentik']); + OauthSetting::create(['provider' => 'bitbucket']); +}); + +it('shows oauth general settings with provider subnavigation', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('General') + ->assertSee('Authentik') + ->assertSee('Bitbucket') + ->assertSee(route('settings.oauth.provider', 'authentik'), false) + ->assertSee(route('settings.oauth.provider', 'bitbucket'), false) + ->assertSee('Disable password registration when OAuth is enabled') + ->assertDontSee('Client Secret'); +}); + +it('shows the registration helper next to the section title in a wider row', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee('flex items-center gap-2', false) + ->assertSee('max-w-2xl', false) + ->assertSee('Disable password registration when OAuth is enabled') + ->assertDontSee('md:w-96', false); +}); + +it('auto saves registration policy without a general save button', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth')) + ->assertSuccessful() + ->assertSee("wire:click='saveRegistrationPolicy'", false) + ->assertDontSee('Save', 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 a provider settings page with a naked okta issuer url example', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth.provider', 'oidc')) + ->assertSuccessful() + ->assertSee('OpenID Connect') + ->assertSee('https://example.okta.com', false) + ->assertDontSee('/oauth2/default', false); +}); + +it('shows provider enable controls as actions without boxed sections', function () { + actingAsInstanceAdmin(); + + $this->withoutMiddleware(DecideWhatToDoWithUser::class) + ->get(route('settings.oauth.provider', 'authentik')) + ->assertSuccessful() + ->assertSee('Enable Authentik') + ->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.provider', 'oidc')) + ->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(); +}); + +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.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(); + + 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('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/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..9d2c7167b9 --- /dev/null +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -0,0 +1,57 @@ + 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('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); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php new file mode 100644 index 0000000000..6be325a0ad --- /dev/null +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -0,0 +1,153 @@ + 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 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'], +]); + +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); From 9b0e2704d22a38f8d77ef952c256e11ebc33328f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:56:44 +0200 Subject: [PATCH 007/118] test: align deployment config redaction expectations --- .../Livewire/ConfigurationCheckerTest.php | 6 ++++++ .../ApplicationConfigurationSnapshotTest.php | 16 ++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/tests/Feature/Livewire/ConfigurationCheckerTest.php b/tests/Feature/Livewire/ConfigurationCheckerTest.php index d9e6729c86..6194b2a1e4 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -110,6 +110,9 @@ it('refreshes stale modal configuration diff before opening changes', function ( }); it('does not render environment variable secret values', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $application = configurationCheckerApplication($this->environment); EnvironmentVariable::create([ 'key' => 'API_TOKEN', @@ -133,6 +136,9 @@ it('does not render environment variable secret values', function () { }); it('renders added environment variables as set without exposing secret values', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $application = configurationCheckerApplication($this->environment); markConfigurationCheckerApplicationDeployed($application); diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php index 20b7c0adc5..261ef94349 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -74,7 +74,7 @@ it('detects redeploy-only domain changes', function () { ->and(collect($diff->changes())->pluck('label'))->toContain('Domains'); }); -it('detects environment variable value changes without exposing secret values', function () { +it('detects environment variable value changes for unlocked variables', function () { $application = snapshotTestApplication(); EnvironmentVariable::create([ 'key' => 'API_TOKEN', @@ -92,13 +92,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); @@ -118,6 +118,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'); }); From 21333f02f9b5f46a975209870fe1220b83e2603f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:35:05 +0200 Subject: [PATCH 008/118] fix(settings): harden OIDC, email, and log drain flows --- app/Auth/Oidc/OidcConfig.php | 2 +- app/Auth/Oidc/OidcDiscoveryService.php | 13 ++ app/Auth/Oidc/OidcTokenValidator.php | 16 ++ app/Auth/Oidc/Socialite/OidcProvider.php | 59 +++++-- app/Livewire/Notifications/Discord.php | 4 +- app/Livewire/Notifications/Telegram.php | 4 +- app/Livewire/Server/LogDrains.php | 14 +- app/Livewire/SettingsEmail.php | 8 + app/Livewire/SettingsOauth.php | 26 ++- app/Services/Auth/OauthLoginService.php | 4 +- .../livewire/server/log-drains.blade.php | 6 +- .../views/livewire/settings-email.blade.php | 12 +- .../views/livewire/settings-oauth.blade.php | 46 ++++-- .../livewire/settings/advanced.blade.php | 3 +- .../LogDrain/LogDrainToggleRollbackTest.php | 45 ++++++ tests/Feature/OauthControllerTest.php | 2 + tests/Feature/OidcOauthControllerTest.php | 1 + .../SettingsEmailProviderExclusivityTest.php | 64 ++++++++ tests/Feature/SettingsOauthTest.php | 19 +++ tests/Unit/OidcDiscoveryServiceTest.php | 14 ++ tests/Unit/OidcProviderPkceTest.php | 152 ++++++++++++++++++ tests/Unit/OidcTokenValidatorTest.php | 3 + 22 files changed, 468 insertions(+), 49 deletions(-) create mode 100644 tests/Feature/LogDrain/LogDrainToggleRollbackTest.php create mode 100644 tests/Feature/SettingsEmailProviderExclusivityTest.php create mode 100644 tests/Unit/OidcProviderPkceTest.php diff --git a/app/Auth/Oidc/OidcConfig.php b/app/Auth/Oidc/OidcConfig.php index 93a88868aa..04cb4effe5 100644 --- a/app/Auth/Oidc/OidcConfig.php +++ b/app/Auth/Oidc/OidcConfig.php @@ -28,7 +28,7 @@ final readonly class OidcConfig 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, + clockSkewSeconds: $setting->clock_skew_seconds ?? 60, ); } } diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php index 0d35ff45cb..3a39f33cfd 100644 --- a/app/Auth/Oidc/OidcDiscoveryService.php +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -12,6 +12,8 @@ class OidcDiscoveryService { public function discover(string $issuerUrl): OidcDiscoveryDocument { + $this->assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.')); + $issuerUrl = rtrim($issuerUrl, '/'); $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); @@ -49,6 +51,8 @@ class OidcDiscoveryService */ public function jwks(string $jwksUri): array { + $this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.')); + $cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri); return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array { @@ -70,4 +74,13 @@ class OidcDiscoveryService 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 index 98dc6d4c00..ee13cf712e 100644 --- a/app/Auth/Oidc/OidcTokenValidator.php +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -41,10 +41,22 @@ class OidcTokenValidator $this->assertAudience($claims, $clientId); $this->assertTimestamps($claims, $clockSkewSeconds); $this->assertNonce($claims, $expectedNonce); + $this->assertSubject($claims); return $claims; } + /** + * @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.'); + } + } + /** * @return array{0: array, 1: array, 2: string, 3: string} */ @@ -104,6 +116,10 @@ class OidcTokenValidator 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.'); } diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php index 65a476d45d..34bb2a1124 100644 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -18,6 +18,8 @@ use Laravel\Socialite\Two\ProviderInterface; class OidcProvider extends AbstractProvider implements ProviderInterface { + private const int OIDC_FLOW_TTL_MINUTES = 10; + /** * @var array */ @@ -65,12 +67,12 @@ class OidcProvider extends AbstractProvider implements ProviderInterface { $config = $this->getConfig(); $nonce = Str::random(40); - $this->request->session()->put($this->nonceSessionKey(), $nonce); + $this->putOidcFlowValue($this->nonceSessionKey($state), $nonce); $extra = ['nonce' => $nonce]; if ($config->usePkce) { $verifier = $this->generateCodeVerifier(); - $this->request->session()->put($this->verifierSessionKey(), $verifier); + $this->putOidcFlowValue($this->verifierSessionKey($state), $verifier); $extra['code_challenge'] = $this->codeChallenge($verifier); $extra['code_challenge_method'] = 'S256'; } @@ -94,6 +96,8 @@ class OidcProvider extends AbstractProvider implements ProviderInterface 'Accept' => 'application/json', 'Authorization' => 'Bearer '.$token, ], + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, ]); $decoded = json_decode((string) $response->getBody(), true); @@ -135,12 +139,17 @@ class OidcProvider extends AbstractProvider implements ProviderInterface $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->tokenValidator->validate( idToken: $idToken, discovery: $discovery, jwks: $this->discoveryService->jwks($discovery->jwksUri), clientId: $config->clientId, - expectedNonce: $this->request->session()->pull($this->nonceSessionKey()), + expectedNonce: $expectedNonce, clockSkewSeconds: $config->clockSkewSeconds, ); @@ -164,8 +173,8 @@ class OidcProvider extends AbstractProvider implements ProviderInterface { $fields = $this->getTokenFields($code); if ($this->getConfig()->usePkce) { - $verifier = $this->request->session()->pull($this->verifierSessionKey()); - if (is_string($verifier) && $verifier !== '') { + $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); + if ($verifier !== null) { $fields['code_verifier'] = $verifier; } } @@ -173,6 +182,8 @@ class OidcProvider extends AbstractProvider implements ProviderInterface $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); @@ -209,13 +220,43 @@ class OidcProvider extends AbstractProvider implements ProviderInterface return $name === '' ? null : $name; } - protected function nonceSessionKey(): string + protected function putOidcFlowValue(string $key, string $value): void { - return 'oidc.nonce'; + $this->request->session()->put($key, [ + 'value' => $value, + 'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp, + ]); } - protected function verifierSessionKey(): string + protected function pullOidcFlowValue(string $key): ?string { - return 'oidc.code_verifier'; + $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/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index 845c25a540..c29bcbb1b1 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -163,7 +163,7 @@ class Discord extends Component } } - public function toggleDiscordEnabled() + public function toggleDiscordEnabled(): void { try { $this->resetErrorBag(); @@ -183,7 +183,7 @@ class Discord extends Component } catch (\Throwable $e) { $this->syncData(); - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index ebb49e0a93..7d11cc7252 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -246,7 +246,7 @@ class Telegram extends Component } } - public function toggleTelegramEnabled() + public function toggleTelegramEnabled(): void { try { $this->resetErrorBag(); @@ -268,7 +268,7 @@ class Telegram extends Component } catch (\Throwable $e) { $this->syncData(); - return handleError($e, $this); + handleError($e, $this); } finally { $this->dispatch('refresh'); } diff --git a/app/Livewire/Server/LogDrains.php b/app/Livewire/Server/LogDrains.php index 18d07ea905..58ca7b71bd 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,8 +177,12 @@ class LogDrains extends Component } } - public function toggleLogDrain(string $type) + 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(); @@ -204,9 +208,15 @@ class LogDrains extends Component $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(); - return handleError($e, $this); + handleError($e, $this); } } diff --git a/app/Livewire/SettingsEmail.php b/app/Livewire/SettingsEmail.php index 9e00936059..a7b1c605f8 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -187,6 +187,10 @@ class SettingsEmail extends Component try { $this->validateSmtpSettings(); + if ($this->smtpEnabled) { + $this->settings->resend_enabled = $this->resendEnabled = false; + } + $this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_host = $this->smtpHost; $this->settings->smtp_port = $this->smtpPort; @@ -212,6 +216,10 @@ class SettingsEmail extends Component try { $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; $this->settings->smtp_from_address = $this->smtpFromAddress; diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 24eddfaf9b..a3025f5938 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -2,11 +2,15 @@ namespace App\Livewire; +use App\Models\InstanceSettings; use App\Models\OauthSetting; +use Illuminate\Validation\ValidationException; use Livewire\Component; class SettingsOauth extends Component { + public InstanceSettings $settings; + public $oauth_settings_map; public ?string $selectedProvider = null; @@ -54,8 +58,9 @@ class SettingsOauth extends Component return redirect()->route('home'); } + $this->settings = instanceSettings(); $this->selectedProvider = $provider; - $this->disable_registration_when_oauth_enabled = (bool) instanceSettings()->disable_registration_when_oauth_enabled; + $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { $carry[$setting->provider] = $this->oauthSettingToArray($setting); @@ -271,10 +276,23 @@ class SettingsOauth extends Component public function submit(): void { - $this->updateOauthSettings($this->selectedProvider); + try { + $this->updateOauthSettings($this->selectedProvider); - if ($this->selectedProvider === null) { - $this->dispatch('success', 'Instance settings updated successfully!'); + 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/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index feb36211c0..25c9c465b2 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -18,8 +18,8 @@ class OauthLoginService public function login(string $provider, object $oauthUser, OauthSetting $oauthSetting): User { $email = strtolower(trim((string) $oauthUser->email)); - if ($email === '') { - throw new HttpException(403, 'OAuth provider did not return an email address'); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new HttpException(403, 'OAuth provider did not return a valid email address'); } $user = $provider === 'oidc' diff --git a/resources/views/livewire/server/log-drains.blade.php b/resources/views/livewire/server/log-drains.blade.php index a36d2febd9..1d1bd9838c 100644 --- a/resources/views/livewire/server/log-drains.blade.php +++ b/resources/views/livewire/server/log-drains.blade.php @@ -25,7 +25,7 @@ Disable New Relic @elseif ($isLogDrainAxiomEnabled || $isLogDrainCustomEnabled) - + Enable New Relic @else @@ -72,7 +72,7 @@ Disable Axiom @elseif ($isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled) - + Enable Axiom @else @@ -113,7 +113,7 @@ Disable Custom FluentBit @elseif ($isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled) - + Enable Custom FluentBit @else diff --git a/resources/views/livewire/settings-email.blade.php b/resources/views/livewire/settings-email.blade.php index eb9cb1b6b0..2b07bc713d 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -36,14 +36,14 @@

SMTP Server

@if ($smtpEnabled) - + Save - + Disable SMTP Server @else - + Enable SMTP Server @endif @@ -70,14 +70,14 @@

Resend

@if ($resendEnabled) - + Save - + Disable Resend @else - + Enable Resend @endif diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 82ad9d5810..8308e88d06 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -31,7 +31,8 @@ helper="When enabled, the normal registration page is hidden if at least one OAuth provider is enabled. OAuth providers can still create users if their provider-specific registration option allows it." />
-
@@ -47,14 +48,16 @@

{{ $oauth_setting['label'] }}

@if ($oauth_setting['enabled']) - + Save - + Disable {{ $oauth_setting['label'] }} @else - + Enable {{ $oauth_setting['label'] }} @endif @@ -64,19 +67,24 @@
- - - @if ($oauth_setting['provider'] == 'azure') - @endif @if ($oauth_setting['provider'] == 'google') - @endif @@ -85,40 +93,44 @@ $oauth_setting['provider'] == 'clerk' || $oauth_setting['provider'] == 'zitadel' || $oauth_setting['provider'] == 'gitlab') - @endif @if ($oauth_setting['provider'] == 'oidc') - @endif
@if ($oauth_setting['provider'] == 'oidc')
- - -
-
-
-
diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index ba9c929ece..25a961fb5d 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -42,7 +42,8 @@
@endif
-
diff --git a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php new file mode 100644 index 0000000000..994c96398b --- /dev/null +++ b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php @@ -0,0 +1,45 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('reverts the persisted enabled flag when starting the log drain fails', function () { + StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom')); + + expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy(); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainNewRelicLicenseKey', 'abc123') + ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') + ->call('toggleLogDrain', 'newrelic') + ->assertSet('isLogDrainNewRelicEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy(); +}); + +it('keeps the enabled flag persisted when starting the log drain succeeds', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainNewRelicLicenseKey', 'abc123') + ->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com') + ->call('toggleLogDrain', 'newrelic') + ->assertSet('isLogDrainNewRelicEnabled', true); + + expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy(); +}); diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php index 4a54030bd7..27660a6bb3 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -80,4 +80,6 @@ it('rejects oauth logins when the provider does not return an email address', fu })->with([ 'null email' => [null], 'blank email' => [' '], + 'malformed email' => ['not-an-email'], + 'missing domain' => ['user@'], ]); diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php index 4199eca5fa..b37694bbc4 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -113,6 +113,7 @@ it('creates the root user when oidc provisions the first account', function () { $response->assertRedirect('/'); $this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']); $this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']); + expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse(); }); it('rejects callbacks for disabled oidc provider', function () { diff --git a/tests/Feature/SettingsEmailProviderExclusivityTest.php b/tests/Feature/SettingsEmailProviderExclusivityTest.php new file mode 100644 index 0000000000..7e6b6ff232 --- /dev/null +++ b/tests/Feature/SettingsEmailProviderExclusivityTest.php @@ -0,0 +1,64 @@ +settings = new InstanceSettings; + $this->settings->id = 0; + $this->settings->save(); + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->rootTeam, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->rootTeam]); +}); + +test('enabling SMTP disables Resend in storage', function () { + $this->settings->update([ + 'resend_enabled' => true, + 'resend_api_key' => 're_test_key', + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + Livewire::test(SettingsEmail::class) + ->set('smtpHost', 'smtp.example.com') + ->set('smtpPort', '587') + ->set('smtpEncryption', 'starttls') + ->set('smtpFromAddress', 'from@example.com') + ->set('smtpFromName', 'Coolify') + ->call('toggleSmtp'); + + $this->settings->refresh(); + expect($this->settings->smtp_enabled)->toBeTrue(); + expect($this->settings->resend_enabled)->toBeFalse(); +}); + +test('enabling Resend disables SMTP in storage', function () { + $this->settings->update([ + 'smtp_enabled' => true, + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => '587', + 'smtp_encryption' => 'starttls', + 'smtp_from_address' => 'from@example.com', + 'smtp_from_name' => 'Coolify', + ]); + + Livewire::test(SettingsEmail::class) + ->set('resendApiKey', 're_test_key') + ->set('smtpFromAddress', 'from@example.com') + ->set('smtpFromName', 'Coolify') + ->call('toggleResend'); + + $this->settings->refresh(); + expect($this->settings->resend_enabled)->toBeTrue(); + expect($this->settings->smtp_enabled)->toBeFalse(); +}); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index e547cde983..0d25ce0f37 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -215,6 +215,25 @@ it('keeps provider disabled in the ui when enable validation fails', function () 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(); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php index 9d2c7167b9..79173a4153 100644 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -55,3 +55,17 @@ it('rejects jwks responses without keys', function () { 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..9726f08b0c --- /dev/null +++ b/tests/Unit/OidcProviderPkceTest.php @@ -0,0 +1,152 @@ +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('does not send 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'); + + parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); + + expect($tokenRequestFields)->not->toHaveKey('code_verifier') + ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); +}); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php index 6be325a0ad..e2546efe18 100644 --- a/tests/Unit/OidcTokenValidatorTest.php +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -111,6 +111,9 @@ it('rejects invalid token claims', function (array $claimOverrides, string $mess '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) { From 8d92059dd6e37b110b45be041d95eab0780df10d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:49:51 +0200 Subject: [PATCH 009/118] fix(auth): refresh OIDC JWKS and block unverified account linking --- .../OidcSigningKeyNotFoundException.php | 5 + app/Auth/Oidc/OidcDiscoveryService.php | 15 +- app/Auth/Oidc/OidcTokenValidator.php | 171 +++++++++++------- app/Auth/Oidc/RsaJwk.php | 112 ------------ app/Auth/Oidc/Socialite/OidcProvider.php | 51 +++++- app/Services/Auth/OauthLoginService.php | 11 +- composer.json | 1 + composer.lock | 22 ++- tests/Feature/OidcOauthControllerTest.php | 15 ++ tests/Unit/OidcDiscoveryServiceTest.php | 19 ++ tests/Unit/OidcTokenValidatorTest.php | 30 +++ 11 files changed, 255 insertions(+), 197 deletions(-) create mode 100644 app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php delete mode 100644 app/Auth/Oidc/RsaJwk.php diff --git a/app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php b/app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php new file mode 100644 index 0000000000..f57ddae554 --- /dev/null +++ b/app/Auth/Oidc/Exceptions/OidcSigningKeyNotFoundException.php @@ -0,0 +1,5 @@ + */ - public function jwks(string $jwksUri): 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); diff --git a/app/Auth/Oidc/OidcTokenValidator.php b/app/Auth/Oidc/OidcTokenValidator.php index ee13cf712e..a8563611dd 100644 --- a/app/Auth/Oidc/OidcTokenValidator.php +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -2,10 +2,21 @@ namespace App\Auth\Oidc; +use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException; use App\Auth\Oidc\Exceptions\OidcTokenException; +use Firebase\JWT\JWK; +use Firebase\JWT\JWT; +use Throwable; class OidcTokenValidator { + /** + * Algorithms we accept for id_token signatures. RS256 only — this is the + * OIDC baseline and a strict allowlist prevents algorithm-confusion and + * "none" attacks. + */ + private const ALLOWED_ALGORITHM = 'RS256'; + /** * @param array $jwks * @return array @@ -18,10 +29,79 @@ class OidcTokenValidator ?string $expectedNonce = null, int $clockSkewSeconds = 60, ): array { - [$header, $claims, $signatureInput, $signature] = $this->parse($idToken); + $kid = $this->extractKid($idToken); - $algorithm = $header['alg'] ?? null; - if ($algorithm !== 'RS256') { + 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.'); } @@ -30,20 +110,33 @@ class OidcTokenValidator throw new OidcTokenException('id_token header is missing kid.'); } - $jwk = $this->findJwk($jwks, $kid); - $publicKey = RsaJwk::toPem($jwk); + return $kid; + } - if (openssl_verify($signatureInput, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) { - throw new OidcTokenException('id_token signature is invalid.'); + private function base64UrlDecode(string $value): string + { + $remainder = strlen($value) % 4; + if ($remainder !== 0) { + $value .= str_repeat('=', 4 - $remainder); } - $this->assertIssuer($claims, $discovery->issuer); - $this->assertAudience($claims, $clientId); - $this->assertTimestamps($claims, $clockSkewSeconds); - $this->assertNonce($claims, $expectedNonce); - $this->assertSubject($claims); + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + if ($decoded === false) { + throw new OidcTokenException('Invalid base64url value in id_token header.'); + } - return $claims; + 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.'); + } } /** @@ -57,41 +150,6 @@ class OidcTokenValidator } } - /** - * @return array{0: array, 1: array, 2: string, 3: string} - */ - private function parse(string $idToken): array - { - $segments = explode('.', $idToken); - if (count($segments) !== 3) { - throw new OidcTokenException('Malformed id_token.'); - } - - $header = json_decode(RsaJwk::base64UrlDecode($segments[0]), true); - $claims = json_decode(RsaJwk::base64UrlDecode($segments[1]), true); - - if (! is_array($header) || ! is_array($claims)) { - throw new OidcTokenException('id_token contains invalid JSON.'); - } - - return [$header, $claims, $segments[0].'.'.$segments[1], RsaJwk::base64UrlDecode($segments[2])]; - } - - /** - * @param array $jwks - * @return array - */ - private function findJwk(array $jwks, string $kid): array - { - foreach ($jwks['keys'] ?? [] as $jwk) { - if (is_array($jwk) && ($jwk['kid'] ?? null) === $kid) { - return $jwk; - } - } - - throw new OidcTokenException('No matching JWKS key found for id_token kid.'); - } - /** * @param array $claims */ @@ -125,23 +183,6 @@ class OidcTokenValidator } } - /** - * @param array $claims - */ - private function assertTimestamps(array $claims, int $clockSkewSeconds): void - { - $now = time(); - $expiration = $claims['exp'] ?? null; - if (! is_int($expiration) || ($expiration + $clockSkewSeconds) < $now) { - throw new OidcTokenException('id_token is expired.'); - } - - $issuedAt = $claims['iat'] ?? null; - if (! is_int($issuedAt) || ($issuedAt - $clockSkewSeconds) > $now) { - throw new OidcTokenException('id_token issued at time is invalid.'); - } - } - /** * @param array $claims */ diff --git a/app/Auth/Oidc/RsaJwk.php b/app/Auth/Oidc/RsaJwk.php deleted file mode 100644 index 3faef77b2c..0000000000 --- a/app/Auth/Oidc/RsaJwk.php +++ /dev/null @@ -1,112 +0,0 @@ - $jwk - */ - public static function toPem(array $jwk): string - { - if (($jwk['kty'] ?? null) !== 'RSA' || ! is_string($jwk['n'] ?? null) || ! is_string($jwk['e'] ?? null)) { - throw new OidcTokenException('JWKS key is not a valid RSA signing key.'); - } - - $modulus = self::base64UrlDecode($jwk['n']); - $exponent = self::base64UrlDecode($jwk['e']); - - $sequence = self::encodeSequence( - self::encodeInteger($modulus). - self::encodeInteger($exponent) - ); - - $bitString = self::encodeBitString($sequence); - $algorithmIdentifier = self::encodeSequence( - self::encodeObjectIdentifier('1.2.840.113549.1.1.1'). - self::encodeNull() - ); - - $subjectPublicKeyInfo = self::encodeSequence($algorithmIdentifier.$bitString); - - return "-----BEGIN PUBLIC KEY-----\n". - chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n"). - "-----END PUBLIC KEY-----\n"; - } - - public static 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.'); - } - - return $decoded; - } - - private static function encodeLength(int $length): string - { - if ($length < 128) { - return chr($length); - } - - $encoded = ltrim(pack('N', $length), "\x00"); - - return chr(0x80 | strlen($encoded)).$encoded; - } - - private static function encodeInteger(string $value): string - { - $value = ltrim($value, "\x00"); - if ($value === '') { - $value = "\x00"; - } - if ((ord($value[0]) & 0x80) !== 0) { - $value = "\x00".$value; - } - - return "\x02".self::encodeLength(strlen($value)).$value; - } - - private static function encodeSequence(string $value): string - { - return "\x30".self::encodeLength(strlen($value)).$value; - } - - private static function encodeBitString(string $value): string - { - $value = "\x00".$value; - - return "\x03".self::encodeLength(strlen($value)).$value; - } - - private static function encodeNull(): string - { - return "\x05\x00"; - } - - private static function encodeObjectIdentifier(string $oid): string - { - $parts = array_map('intval', explode('.', $oid)); - $encoded = chr((40 * $parts[0]) + $parts[1]); - - foreach (array_slice($parts, 2) as $part) { - $stack = [chr($part & 0x7F)]; - $part >>= 7; - while ($part > 0) { - array_unshift($stack, chr(($part & 0x7F) | 0x80)); - $part >>= 7; - } - $encoded .= implode('', $stack); - } - - return "\x06".self::encodeLength(strlen($encoded)).$encoded; - } -} diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php index 34bb2a1124..b9fc16f9c0 100644 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -3,6 +3,7 @@ namespace App\Auth\Oidc\Socialite; use App\Auth\Oidc\Exceptions\OidcException; +use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException; use App\Auth\Oidc\OidcConfig; use App\Auth\Oidc\OidcDiscoveryDocument; use App\Auth\Oidc\OidcDiscoveryService; @@ -144,16 +145,17 @@ class OidcProvider extends AbstractProvider implements ProviderInterface throw new OidcException('OIDC login session expired. Please try again.'); } - $claims = $this->tokenValidator->validate( - idToken: $idToken, - discovery: $discovery, - jwks: $this->discoveryService->jwks($discovery->jwksUri), - clientId: $config->clientId, - expectedNonce: $expectedNonce, - clockSkewSeconds: $config->clockSkewSeconds, - ); + $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 */ @@ -166,6 +168,39 @@ class OidcProvider extends AbstractProvider implements ProviderInterface 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 */ diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index 25c9c465b2..b0c47a3958 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -66,7 +66,7 @@ class OauthLoginService throw new HttpException(403, 'OIDC provider did not verify the email address'); } - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject) { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified) { $identity = OauthIdentity::where([ 'provider' => 'oidc', 'issuer' => $issuer, @@ -84,6 +84,15 @@ class OauthLoginService } $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'); diff --git a/composer.json b/composer.json index 9415aa6246..923bc841bd 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "php": "^8.4", "danharrin/livewire-rate-limiting": "^2.1.0", "doctrine/dbal": "^4.4.1", + "firebase/php-jwt": "7.1.0", "guzzlehttp/guzzle": "^7.10.0", "laravel/fortify": "^1.34.0", "laravel/framework": "^12.49.0", diff --git a/composer.lock b/composer.lock index 7d958a9cc3..9866c84740 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": "64b77285a7140ce68e83db2659e9a21d", + "content-hash": "115a84e56a483f0c336c220b4d0246d7", "packages": [ { "name": "aws/aws-crt-php", @@ -1035,16 +1035,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.5", + "version": "v7.1.0", "source": { "type": "git", "url": "https://github.com/googleapis/php-jwt.git", - "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", - "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", "shasum": "" }, "require": { @@ -1053,6 +1053,7 @@ "require-dev": { "guzzlehttp/guzzle": "^7.4", "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1061,7 +1062,8 @@ }, "suggest": { "ext-sodium": "Support EdDSA (Ed25519) signatures", - "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" }, "type": "library", "autoload": { @@ -1086,16 +1088,16 @@ } ], "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", + "homepage": "https://github.com/googleapis/php-jwt", "keywords": [ "jwt", "php" ], "support": { "issues": "https://github.com/googleapis/php-jwt/issues", - "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5" + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" }, - "time": "2026-04-01T20:38:03+00:00" + "time": "2026-06-11T17:54:14+00:00" }, { "name": "fruitcake/php-cors", @@ -18072,5 +18074,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php index b37694bbc4..6851ba97a2 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -93,6 +93,21 @@ it('creates a new oidc user when provider registration is allowed while normal r ]); }); +it('rejects linking an unverified oidc email to an existing local account', function () { + $user = User::factory()->create(['email' => 'victim@example.com']); + + fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]); + + $response = $this->from('/login')->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/login'); + $this->assertGuest(); + $this->assertDatabaseMissing('oauth_identities', [ + 'user_id' => $user->id, + 'provider' => 'oidc', + ]); +}); + it('rejects new oidc users when neither normal nor provider registration is enabled', function () { fakeOidcProvider(['email' => 'blocked@example.com']); diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php index 79173a4153..458dfeedef 100644 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -38,6 +38,25 @@ it('fetches and caches discovery documents and jwks', function () { 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([ diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php index e2546efe18..3ca021de50 100644 --- a/tests/Unit/OidcTokenValidatorTest.php +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -1,5 +1,6 @@ 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); From e1b47d0cf00a09b2bf98e1d59581394ba881e20d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:25:50 +0200 Subject: [PATCH 010/118] fix(settings): bind settings resource in advanced options Pass the settings model as a bound prop so the advanced registration toggle can resolve its resource correctly. --- ...encrypt_application_deployment_configuration_columns.php | 6 ++++++ resources/views/livewire/settings/advanced.blade.php | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index 25a961fb5d..9a8c99854a 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -42,7 +42,7 @@
@endif
- From c656892738d4b6d20437489d7462df624854d2bb Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:33:23 +0200 Subject: [PATCH 011/118] fix(auth): store OIDC raw claims as arrays Handle missing provider payloads by persisting an empty array and add coverage for multi-audience OIDC azp validation. --- app/Services/Auth/OauthLoginService.php | 8 +++-- tests/Feature/OidcOauthControllerTest.php | 37 +++++++++++++++++++++++ tests/Unit/OidcTokenValidatorTest.php | 1 + 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index b0c47a3958..f1c3b4d827 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -66,7 +66,9 @@ class OauthLoginService throw new HttpException(403, 'OIDC provider did not verify the email address'); } - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified) { + $rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : []; + + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims) { $identity = OauthIdentity::where([ 'provider' => 'oidc', 'issuer' => $issuer, @@ -76,7 +78,7 @@ class OauthLoginService if ($identity) { $identity->update([ 'email' => $email, - 'raw_claims' => $oauthUser->user, + 'raw_claims' => $rawClaims, 'last_login_at' => now(), ]); @@ -107,7 +109,7 @@ class OauthLoginService 'issuer' => $issuer, 'provider_user_id' => $subject, 'email' => $email, - 'raw_claims' => $oauthUser->user, + 'raw_claims' => $rawClaims, 'last_login_at' => now(), ]); diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php index 6851ba97a2..6ea861ec1f 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -131,6 +131,43 @@ it('creates the root user when oidc provisions the first account', function () { expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse(); }); +it('persists raw claims as an array on the oauth identity', function () { + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + fakeOidcProvider(['email' => 'claims@example.com']); + + $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); + + $identity = OauthIdentity::where('email', 'claims@example.com')->first(); + expect($identity->raw_claims)->toBeArray() + ->and($identity->raw_claims['sub'])->toBe('okta-user-1'); +}); + +it('stores empty raw claims when the provider returns no user payload', function () { + OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]); + + $user = (new OidcUser)->setIdTokenClaims([ + 'iss' => 'https://idp.example.com', + 'sub' => 'okta-no-payload', + 'email_verified' => true, + ])->map([ + 'id' => 'okta-no-payload', + 'name' => 'No Payload', + 'email' => 'nopayload@example.com', + ]); + $user->user = null; + + $provider = Mockery::mock(); + $provider->shouldReceive('setConfig')->andReturnSelf(); + $provider->shouldReceive('user')->andReturn($user); + Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider); + + $this->get(route('auth.callback', 'oidc'))->assertRedirect('/'); + + $identity = OauthIdentity::where('email', 'nopayload@example.com')->first(); + expect($identity->raw_claims)->toBe([]); +}); + it('rejects callbacks for disabled oidc provider', function () { OauthSetting::where('provider', 'oidc')->update(['enabled' => false]); diff --git a/tests/Unit/OidcTokenValidatorTest.php b/tests/Unit/OidcTokenValidatorTest.php index 3ca021de50..9b1d9a24c3 100644 --- a/tests/Unit/OidcTokenValidatorTest.php +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -108,6 +108,7 @@ it('rejects invalid token claims', function (array $claimOverrides, string $mess })->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'], From a1dbde34121954fb13daf980373dbb2e9fea6db3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:05:52 +0200 Subject: [PATCH 012/118] fix(auth): block OIDC token exchange without PKCE verifier Throw a session-expired error when the PKCE verifier is missing and keep the settings component mount redirect-compatible. --- app/Auth/Oidc/Socialite/OidcProvider.php | 6 ++++-- app/Livewire/SettingsOauth.php | 5 ++++- tests/Unit/OidcProviderPkceTest.php | 10 +++------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/Auth/Oidc/Socialite/OidcProvider.php b/app/Auth/Oidc/Socialite/OidcProvider.php index b9fc16f9c0..383b0cc910 100644 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -209,9 +209,11 @@ class OidcProvider extends AbstractProvider implements ProviderInterface $fields = $this->getTokenFields($code); if ($this->getConfig()->usePkce) { $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); - if ($verifier !== null) { - $fields['code_verifier'] = $verifier; + if ($verifier === null) { + throw new OidcException('OIDC login session expired. Please try again.'); } + + $fields['code_verifier'] = $verifier; } $response = $this->getHttpClient()->post($this->getTokenUrl(), [ diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index 8b4c902d5f..d038d9a956 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -5,6 +5,7 @@ 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; @@ -55,7 +56,7 @@ class SettingsOauth extends Component return $rules; } - public function mount(?string $provider = null) + public function mount(?string $provider = null): ?RedirectResponse { if (! isInstanceAdmin()) { return redirect()->route('home'); @@ -73,6 +74,8 @@ class SettingsOauth extends Component if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { abort(404); } + + return null; } private function updateOauthSettings(?string $provider = null): void diff --git a/tests/Unit/OidcProviderPkceTest.php b/tests/Unit/OidcProviderPkceTest.php index 9726f08b0c..b92ff58ffe 100644 --- a/tests/Unit/OidcProviderPkceTest.php +++ b/tests/Unit/OidcProviderPkceTest.php @@ -1,5 +1,6 @@ and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); }); -it('does not send an expired oidc pkce verifier during token exchange', function () { +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', @@ -144,9 +145,4 @@ it('does not send an expired oidc pkce verifier during token exchange', function $provider->setHttpClient(new Client(['handler' => $handler])); $provider->getAccessTokenResponse('authorization-code'); - - parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields); - - expect($tokenRequestFields)->not->toHaveKey('code_verifier') - ->and($session->has('oidc.code_verifier.state-value'))->toBeFalse(); -}); +})->throws(OidcException::class, 'OIDC login session expired. Please try again.'); From c9264dde2e5432b4054105b7d3132814b4088a18 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:10:55 +0200 Subject: [PATCH 013/118] fix(auth): avoid caching invalid OIDC discovery documents --- app/Auth/Oidc/OidcDiscoveryService.php | 16 ++++++-------- tests/Unit/OidcDiscoveryServiceTest.php | 29 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/app/Auth/Oidc/OidcDiscoveryService.php b/app/Auth/Oidc/OidcDiscoveryService.php index e3223ea98b..0847afc9a7 100644 --- a/app/Auth/Oidc/OidcDiscoveryService.php +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -17,7 +17,7 @@ class OidcDiscoveryService $issuerUrl = rtrim($issuerUrl, '/'); $cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl); - $payload = Cache::remember($cacheKey, 3600, function () use ($issuerUrl): array { + return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument { $url = $issuerUrl.'/.well-known/openid-configuration'; try { @@ -35,15 +35,13 @@ class OidcDiscoveryService throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.'); } - return $json; + $discovery = OidcDiscoveryDocument::fromArray($json); + if (rtrim($discovery->issuer, '/') !== $issuerUrl) { + throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); + } + + return $discovery; }); - - $discovery = OidcDiscoveryDocument::fromArray($payload); - if (rtrim($discovery->issuer, '/') !== $issuerUrl) { - throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.'); - } - - return $discovery; } /** diff --git a/tests/Unit/OidcDiscoveryServiceTest.php b/tests/Unit/OidcDiscoveryServiceTest.php index 458dfeedef..18c358fd13 100644 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -38,6 +38,35 @@ it('fetches and caches discovery documents and jwks', function () { 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') From fc32bc6b6c9d260d44ea3e623475c255233fc0f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:13:39 +0200 Subject: [PATCH 014/118] feat(auth): auto-join OAuth users to root team Add an OAuth setting that provisions newly registered users as Root team members without creating a personal team. --- app/Livewire/SettingsOauth.php | 3 ++ app/Models/OauthSetting.php | 3 +- app/Services/Auth/OauthLoginService.php | 30 ++++++++++++++-- ...join_root_team_to_oauth_settings_table.php | 28 +++++++++++++++ .../views/livewire/settings-oauth.blade.php | 8 +++++ tests/Feature/OidcOauthControllerTest.php | 34 +++++++++++++++++++ tests/Feature/SettingsOauthTest.php | 10 ++++-- 7 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index d038d9a956..a3ee2c9873 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -42,6 +42,7 @@ class SettingsOauth extends Component $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'; @@ -148,6 +149,7 @@ class SettingsOauth extends Component '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), @@ -196,6 +198,7 @@ class SettingsOauth extends Component '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, diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 25e38b4d1c..bc6fc06c17 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -11,13 +11,14 @@ class OauthSetting extends Model { use HasFactory; - protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'require_email_verified', 'use_pkce', 'clock_skew_seconds']; + 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', diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index f1c3b4d827..f656fad08f 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -44,7 +44,7 @@ class OauthLoginService throw new HttpException(403, 'Registration is disabled'); } - return $this->createUser($oauthUser->name ?: $email, $email); + return $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); } private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User @@ -100,7 +100,7 @@ class OauthLoginService throw new HttpException(403, 'Registration is disabled'); } - $user = $this->createUser($oauthUser->name ?: $email, $email); + $user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting); } OauthIdentity::create([ @@ -122,7 +122,7 @@ class OauthLoginService return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration; } - private function createUser(string $name, string $email): User + private function createUser(string $name, string $email, OauthSetting $oauthSetting): User { if (User::count() === 0) { $user = (new User)->forceFill([ @@ -143,10 +143,34 @@ class OauthLoginService 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/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/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 8308e88d06..1ff75512e4 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -136,6 +136,14 @@
@endif +
+
+ +
+
@endif diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php index 6ea861ec1f..21b335016e 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -93,6 +93,40 @@ it('creates a new oidc user when provider registration is allowed while normal r ]); }); +it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () { + Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]); + (new User)->forceFill([ + 'id' => 0, + 'name' => 'Root User', + 'email' => 'root@example.com', + 'password' => 'password', + ])->save(); + + OauthSetting::where('provider', 'oidc')->update([ + 'allow_registration' => true, + 'auto_join_root_team' => true, + ]); + + fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']); + + $response = $this->get(route('auth.callback', 'oidc')); + + $response->assertRedirect('/'); + $user = User::whereEmail('root-member@example.com')->first(); + expect($user)->not->toBeNull() + ->and($user->teams()->count())->toBe(1); + + $rootMembership = $user->teams()->where('teams.id', 0)->first(); + expect($rootMembership)->not->toBeNull() + ->and($rootMembership->pivot->role)->toBe('member'); + + $this->assertDatabaseMissing('teams', [ + 'name' => "Root Member's Team", + ]); + expect(session('currentTeam')->id)->toBe(0); + $this->assertAuthenticatedAs($user); +}); + it('rejects linking an unverified oidc email to an existing local account', function () { $user = User::factory()->create(['email' => 'victim@example.com']); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index 0d25ce0f37..e55cdac4f1 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -26,6 +26,9 @@ function actingAsInstanceAdmin(): User } beforeEach(function () { + $this->withoutVite(); + config()->set('app.maintenance.driver', 'file'); + InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]); Once::flush(); OauthSetting::create(['provider' => 'oidc']); @@ -124,7 +127,8 @@ it('defaults oidc user creation and verified email requirement to enabled', func $setting = OauthSetting::where('provider', 'oidc')->first(); expect($setting->allow_registration)->toBeTrue() - ->and($setting->require_email_verified)->toBeTrue(); + ->and($setting->require_email_verified)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeFalse(); }); it('persists oidc oauth settings from livewire', function () { @@ -139,6 +143,7 @@ it('persists oidc oauth settings from livewire', function () { ->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') @@ -150,7 +155,8 @@ it('persists oidc oauth settings from livewire', function () { ->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->allow_registration)->toBeTrue() + ->and($setting->auto_join_root_team)->toBeTrue(); expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); }); From 14ecadc0debb2a86975ea4d48dc6abc8ddc3ceb0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:24:12 +0200 Subject: [PATCH 015/118] fix(services): avoid reading bind-mount files when syncing volumes Stop catting remote file contents into memory for non-git bind mounts in getFilesystemVolumesFromServer; git-based volumes still load via loadStorageOnServer(). --- bootstrap/helpers/services.php | 19 +++++++++---------- tests/Unit/LocalFileVolumeContentSizeTest.php | 13 +++++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 20b184a013..e46769a15c 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -139,7 +139,7 @@ function replaceVariables(string $variable): Stringable function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Application $oneService, bool $isInit = false) { try { - if ($oneService->getMorphClass() === \App\Models\Application::class) { + if ($oneService->getMorphClass() === Application::class) { $workdir = $oneService->workdir(); $server = $oneService->destination->server; } else { @@ -167,13 +167,12 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { - // If its a file & exists - $filesystemContent = instant_remote_process(["cat $fileLocation"], $server); if ($fileVolume->is_based_on_git) { - $fileVolume->content = $filesystemContent; + $fileVolume->loadStorageOnServer(); + } else { + $fileVolume->is_directory = false; + $fileVolume->save(); } - $fileVolume->is_directory = false; - $fileVolume->save(); } elseif ($isDir === 'OK') { // If its a directory & exists $fileVolume->content = null; @@ -204,7 +203,7 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli instant_remote_process(["mkdir -p $fileLocation"], $server); } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -214,7 +213,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) $name = data_get($resource, 'name'); $dockerComposeRaw = data_get($resource, 'service.docker_compose_raw'); if (! $dockerComposeRaw) { - throw new \Exception('No compose file found or not a valid YAML file.'); + throw new Exception('No compose file found or not a valid YAML file.'); } $dockerCompose = Yaml::parse($dockerComposeRaw); @@ -396,7 +395,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource) } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { return handleError($e); } } @@ -495,7 +494,7 @@ function applyServiceApplicationPrerequisites(Service $service): void } } } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Log error but don't throw - prerequisites are nice-to-have, not critical Log::error('Failed to apply service application prerequisites', [ 'service_id' => $service->id, diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1fd315884e..0ad69e28aa 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -64,3 +64,16 @@ it('exposes the too-large flag via toArray for Livewire serialization', function expect($array)->toHaveKey('is_too_large'); expect($array['is_too_large'])->toBeTrue(); }); + +it('does not read regular bind-mounted file contents while loading service settings', function () { + $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); + $filesystemSync = str($helpers) + ->after('function getFilesystemVolumesFromServer') + ->before('function updateCompose'); + + expect($filesystemSync->value()) + ->not->toContain('instant_remote_process(["cat $fileLocation"]'); + expect($filesystemSync->value()) + ->toContain('if ($fileVolume->is_based_on_git)') + ->toContain('$fileVolume->loadStorageOnServer();'); +}); From 2749f45ba6e464681929e9485730395452a246f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:21:00 +0200 Subject: [PATCH 016/118] fix: cap remote logs, tasks, and config output sizes Bound proxy config, docker compose, scheduled task, log viewer, and dynamic proxy config reads so large remote output cannot overwhelm PHP. --- app/Actions/Proxy/GetProxyConfiguration.php | 10 ++- app/Jobs/ScheduledTaskJob.php | 12 ++- app/Livewire/Project/Shared/GetLogs.php | 39 ++++++-- .../Server/Proxy/DynamicConfigurations.php | 35 +++++++- app/Models/Application.php | 15 +++- tests/Unit/RemoteOutputSizeLimitsTest.php | 90 +++++++++++++++++++ 6 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/RemoteOutputSizeLimitsTest.php diff --git a/app/Actions/Proxy/GetProxyConfiguration.php b/app/Actions/Proxy/GetProxyConfiguration.php index 159f122526..7df16c52b4 100644 --- a/app/Actions/Proxy/GetProxyConfiguration.php +++ b/app/Actions/Proxy/GetProxyConfiguration.php @@ -13,6 +13,8 @@ class GetProxyConfiguration { use AsAction; + public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + public function handle(Server $server, bool $forceRegenerate = false): string { $proxyType = $server->proxyType(); @@ -98,11 +100,17 @@ class GetProxyConfiguration private function backfillFromDisk(Server $server): ?string { $proxy_path = $server->proxyPath(); + $configurationPath = escapeshellarg("$proxy_path/docker-compose.yml"); + $readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1; $result = instant_remote_process([ "mkdir -p $proxy_path", - "cat $proxy_path/docker-compose.yml 2>/dev/null", + "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi", ], $server, false); + if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__') { + throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.'); + } + if (! empty(trim($result ?? ''))) { $server->proxy->last_saved_proxy_configuration = $result; $server->save(); diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index dc11ec89e7..02f3daa3b4 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024; + /** * The number of times the job may be attempted. */ @@ -148,7 +150,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $exec = "docker exec {$containerName} {$cmd}"; + $execCommand = "docker exec {$containerName} {$cmd}"; + $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); @@ -204,6 +207,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue } } + private function boundedTaskCommand(string $command): string + { + $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + } + /** * Calculate the number of seconds to wait before retrying the job. */ diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index d0121bdc51..fa575073f7 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -25,6 +25,8 @@ class GetLogs extends Component { public const MAX_LOG_LINES = 50000; + public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024; + public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public string $outputs = ''; @@ -154,14 +156,12 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -170,22 +170,39 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } + $command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); + // Collect new logs into temporary variable first to prevent flickering // (avoids clearing output before new data is ready) // Use array accumulation + implode for O(n) instead of O(n²) string concatenation $logChunks = []; - Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { - $logChunks[] = removeAnsiColors($output); + $accumulatedBytes = 0; + $truncated = false; + Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) { + if ($truncated) { + return; + } + + $output = removeAnsiColors($output); + $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; + if (strlen($output) > $remainingBytes) { + $logChunks[] = substr($output, 0, max(0, $remainingBytes)); + $truncated = true; + + return; + } + + $logChunks[] = $output; + $accumulatedBytes += strlen($output); }); $newOutputs = implode('', $logChunks); @@ -198,6 +215,10 @@ class GetLogs extends Component })->join("\n"); } + if ($truncated) { + $newOutputs .= "\n\n[... Output truncated at 5MB limit ...]"; + } + // Only update outputs after new data is ready (atomic update prevents flicker) $this->outputs = $newOutputs; } @@ -239,6 +260,7 @@ class GetLogs extends Component $command = $command[0]; } + $command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); // Use array accumulation + implode for O(n) instead of O(n²) string concatenation @@ -287,6 +309,11 @@ class GetLogs extends Component return sanitizeLogsForExport($allLogs); } + private function boundedLogCommand(string $command, int $maxBytes): string + { + return "({$command}) 2>&1 | head -c ".($maxBytes + 1); + } + public function render() { return view('livewire.project.shared.get-logs'); diff --git a/app/Livewire/Server/Proxy/DynamicConfigurations.php b/app/Livewire/Server/Proxy/DynamicConfigurations.php index f824645aa6..6351dace86 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurations.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurations.php @@ -11,6 +11,12 @@ class DynamicConfigurations extends Component { use AuthorizesRequests; + public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024; + + public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024; + + public const MAX_CONFIGURATION_FILES = 100; + public ?Server $server = null; public $parameters = []; @@ -44,15 +50,36 @@ class DynamicConfigurations extends Component return handleError($e, $this); } $proxy_path = $this->server->proxyPath(); - $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); + $fileLimit = self::MAX_CONFIGURATION_FILES + 1; + $files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = $files->map(fn ($file) => trim($file)); $files = $files->sort(); $contents = collect([]); - foreach ($files as $file) { + $skippedFiles = collect([]); + $totalBytes = 0; + if ($files->count() > self::MAX_CONFIGURATION_FILES) { + $skippedFiles->push('additional files'); + } + foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) { $without_extension = str_replace('.', '|', $file); - $content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); - $contents[$without_extension] = $content ?? ''; + $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}"); + $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1; + $content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server); + $content = $content ?? ''; + $contentBytes = strlen($content); + + if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) { + $skippedFiles->push($file); + + continue; + } + + $contents[$without_extension] = $content; + $totalBytes += $contentBytes; + } + if ($skippedFiles->isNotEmpty()) { + $this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', ')); } $this->contents = $contents; $this->dispatch('$refresh'); diff --git a/app/Models/Application.php b/app/Models/Application.php index 732142b0de..6f11027171 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -119,6 +119,8 @@ class Application extends BaseModel { use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes; + public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024; + private static $parserVersion = '5'; protected $fillable = [ @@ -1936,6 +1938,9 @@ class Application extends BaseModel $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; $fileList = collect([".$workdir$composeFile"]); + $composeFilePath = escapeshellarg(".$workdir$composeFile"); + $composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1; + $readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi"; $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); @@ -1966,7 +1971,7 @@ class Application extends BaseModel 'git sparse-checkout init', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } else { $commands = collect([ @@ -1978,11 +1983,14 @@ class Application extends BaseModel 'git sparse-checkout init --cone', "git sparse-checkout set {$fileList->implode(' ')}", 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", + $readComposeFile, ]); } try { $composeFileContent = instant_remote_process($commands, $this->destination->server); + if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__') { + throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.'); + } } catch (\Exception $e) { // Restore original values on failure only $this->docker_compose_location = $initialDockerComposeLocation; @@ -1998,6 +2006,9 @@ class Application extends BaseModel } throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); } + if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) { + throw $e; + } throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); } finally { // Cleanup only - restoration happens in catch block diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php new file mode 100644 index 0000000000..e010970bba --- /dev/null +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -0,0 +1,90 @@ +toContain('MAX_CONFIGURATION_FILE_SIZE_BYTES') + ->toContain('MAX_TOTAL_CONFIGURATION_SIZE_BYTES') + ->toContain('MAX_CONFIGURATION_FILES') + ->toContain('head -c') + ->toContain('$totalBytes'); + + expect(DynamicConfigurations::MAX_CONFIGURATION_FILE_SIZE_BYTES)->toBe(1024 * 1024) + ->and(DynamicConfigurations::MAX_TOTAL_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and(DynamicConfigurations::MAX_CONFIGURATION_FILES)->toBe(100); +}); + +it('bounds regular log viewer output before it reaches PHP', function () { + $source = remoteOutputSource('app/Livewire/Project/Shared/GetLogs.php'); + + expect($source) + ->toContain('MAX_DISPLAY_SIZE_BYTES') + ->toContain('boundedLogCommand(') + ->toContain('[... Output truncated at'); + + $method = new ReflectionMethod(GetLogs::class, 'boundedLogCommand'); + $command = $method->invoke(new GetLogs, 'docker logs example', 100); + + expect(GetLogs::MAX_DISPLAY_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toBe('(docker logs example) 2>&1 | head -c 101'); +}); + +it('bounds docker compose files loaded from git before parsing', function () { + $source = remoteOutputSource('app/Models/Application.php'); + + expect($source) + ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES') + ->toContain('MAX_DOCKER_COMPOSE_SIZE_BYTES + 1') + ->toContain('head -c'); + + expect(Application::MAX_DOCKER_COMPOSE_SIZE_BYTES)->toBe(5 * 1024 * 1024); +}); + +it('bounds scheduled task output before storing or notifying', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + + expect($source) + ->toContain('MAX_OUTPUT_SIZE_BYTES') + ->toContain('head -c {$maxOutputBytes}') + ->toContain('[... Output truncated at'); + + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'printf hello'); + $failureCommand = $method->invoke($reflection->newInstanceWithoutConstructor(), "bash -c 'printf failure; exit 7'"); + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -c '.escapeshellarg($command), $commandOutput, $commandExitCode); + exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); + + expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) + ->and($command)->toContain('head -c 5242880') + ->and($exitCode)->toBe(0) + ->and($commandExitCode)->toBe(0) + ->and(implode("\n", $commandOutput))->toBe('hello') + ->and($failureExitCode)->toBe(7) + ->and(implode("\n", $failureOutput))->toBe('failure'); +}); + +it('bounds proxy configuration backfill before storing it', function () { + $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); + + expect($source) + ->toContain('MAX_CONFIGURATION_SIZE_BYTES') + ->toContain('MAX_CONFIGURATION_SIZE_BYTES + 1') + ->toContain('head -c') + ->toContain('Proxy configuration exceeds'); + + expect(GetProxyConfiguration::MAX_CONFIGURATION_SIZE_BYTES)->toBe(5 * 1024 * 1024); +}); From 60129c2c4701431a18c15fe4c7b40e4f8607c07c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:02:51 +0200 Subject: [PATCH 017/118] fix: measure log size before ANSI strip; harden tasks and volumes Truncate GetLogs against raw byte length, then strip ANSI colors. Run scheduled-task docker exec with explicit sudo docker and no_sudo to avoid double sudo rewriting on non-root servers. Mark git-based file volumes as files before refreshing content from the server. --- app/Jobs/ScheduledTaskJob.php | 5 ++- app/Livewire/Project/Shared/GetLogs.php | 15 ++++--- bootstrap/helpers/services.php | 5 +-- tests/Feature/GetLogsCommandInjectionTest.php | 41 ++++++++++++++++++- tests/Unit/LocalFileVolumeContentSizeTest.php | 11 +++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 20 +++++++++ 6 files changed, 83 insertions(+), 14 deletions(-) diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 02f3daa3b4..d24f36350b 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -150,11 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue foreach ($this->containers as $containerName) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; - $execCommand = "docker exec {$containerName} {$cmd}"; + $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker'; + $execCommand = "{$dockerCommand} exec {$containerName} {$cmd}"; $exec = $this->boundedTaskCommand($execCommand); // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // See: https://github.com/coollabsio/coolify/issues/6736 - $this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); + $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true); $this->task_log->update([ 'status' => 'success', 'message' => $this->task_output, diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index fa575073f7..67a040ef77 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -192,17 +192,17 @@ class GetLogs extends Component return; } - $output = removeAnsiColors($output); $remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes; - if (strlen($output) > $remainingBytes) { - $logChunks[] = substr($output, 0, max(0, $remainingBytes)); + $outputBytes = strlen($output); + if ($outputBytes > $remainingBytes) { + $logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes))); $truncated = true; return; } - $logChunks[] = $output; - $accumulatedBytes += strlen($output); + $logChunks[] = removeAnsiColors($output); + $accumulatedBytes += $outputBytes; }); $newOutputs = implode('', $logChunks); @@ -274,20 +274,19 @@ class GetLogs extends Component return; } - $output = removeAnsiColors($output); $outputBytes = strlen($output); if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; if ($remaining > 0) { - $logChunks[] = substr($output, 0, $remaining); + $logChunks[] = removeAnsiColors(substr($output, 0, $remaining)); } $truncated = true; return; } - $logChunks[] = $output; + $logChunks[] = removeAnsiColors($output); $accumulatedBytes += $outputBytes; }); diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index e46769a15c..05430930b7 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -167,11 +167,10 @@ function getFilesystemVolumesFromServer(ServiceApplication|ServiceDatabase|Appli $isDir = instant_remote_process(["test -d $fileLocation && echo OK || echo NOK"], $server); if ($isFile === 'OK') { + $fileVolume->is_directory = false; + $fileVolume->save(); if ($fileVolume->is_based_on_git) { $fileVolume->loadStorageOnServer(); - } else { - $fileVolume->is_directory = false; - $fileVolume->save(); } } elseif ($isDir === 'OK') { // If its a directory & exists diff --git a/tests/Feature/GetLogsCommandInjectionTest.php b/tests/Feature/GetLogsCommandInjectionTest.php index db75f7b757..c920b94ede 100644 --- a/tests/Feature/GetLogsCommandInjectionTest.php +++ b/tests/Feature/GetLogsCommandInjectionTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Shared\GetLogs; use App\Models\Application; use App\Models\Environment; +use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server; use App\Models\StandaloneDocker; @@ -10,6 +11,8 @@ use App\Models\Team; use App\Models\User; use App\Support\ValidationPatterns; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Process\FakeProcessResult; +use Illuminate\Support\Facades\Process; use Livewire\Attributes\Locked; use Livewire\Livewire; @@ -20,7 +23,11 @@ beforeEach(function () { $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team, ['role' => 'owner']); - $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); // Server::created auto-creates a StandaloneDocker, reuse it $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); $this->project = Project::factory()->create(['team_id' => $this->team->id]); @@ -67,6 +74,38 @@ describe('GetLogs locked properties', function () { }); describe('GetLogs Livewire action validation', function () { + test('getLogs marks ANSI-colored output truncated based on raw bytes', function () { + $this->server->settings->fill([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ])->save(); + $server = Server::with('settings')->find($this->server->id); + $output = "\e[31m".str_repeat('a', GetLogs::MAX_DISPLAY_SIZE_BYTES - 4); + + expect(strlen($output))->toBe(GetLogs::MAX_DISPLAY_SIZE_BYTES + 1); + + Process::shouldReceive('timeout')->once()->andReturnSelf(); + Process::shouldReceive('run')->andReturnUsing(function (string $command, ?callable $callback = null) use ($output): FakeProcessResult { + if ($callback) { + $callback('out', $output); + } + + return new FakeProcessResult(command: $command); + }); + + $component = new GetLogs; + $component->server = $server; + $component->resource = $this->application; + $component->container = 'test-container'; + $component->showTimeStamps = false; + $component->getLogs(true); + + expect($component->outputs) + ->toContain('[... Output truncated at 5MB limit ...]') + ->not->toContain("\e[31m"); + }); + test('getLogs rejects invalid container name', function () { // Make server functional by setting settings directly $this->server->settings->fill([ diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 0ad69e28aa..1954539785 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -77,3 +77,14 @@ it('does not read regular bind-mounted file contents while loading service setti ->toContain('if ($fileVolume->is_based_on_git)') ->toContain('$fileVolume->loadStorageOnServer();'); }); + +it('marks git-based file volumes as files before refreshing their content', function () { + $helpers = file_get_contents(base_path('bootstrap/helpers/services.php')); + $fileBranch = str($helpers) + ->after("if (\$isFile === 'OK') {") + ->before("} elseif (\$isDir === 'OK') {"); + + expect($fileBranch->value())->toMatch( + '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' + ); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index e010970bba..b8341dd73f 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -5,6 +5,7 @@ use App\Jobs\ScheduledTaskJob; use App\Livewire\Project\Shared\GetLogs; use App\Livewire\Server\Proxy\DynamicConfigurations; use App\Models\Application; +use App\Models\Server; function remoteOutputSource(string $path): string { @@ -77,6 +78,25 @@ it('bounds scheduled task output before storing or notifying', function () { ->and(implode("\n", $failureOutput))->toBe('failure'); }); +it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { + $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), 'sudo docker exec example true'); + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $rewrittenCommand = parseCommandsByLineForSudo(collect([$command]), $server)[0]; + + exec('bash -n -c '.escapeshellarg($command), $output, $exitCode); + exec('bash -n -c '.escapeshellarg($rewrittenCommand).' 2>/dev/null', $rewrittenOutput, $rewrittenExitCode); + + expect($source) + ->toContain("\$dockerCommand = \$this->server->isNonRoot() ? 'sudo docker' : 'docker'") + ->toContain('instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true') + ->and($exitCode)->toBe(0) + ->and($rewrittenExitCode)->not->toBe(0); +}); + it('bounds proxy configuration backfill before storing it', function () { $source = remoteOutputSource('app/Actions/Proxy/GetProxyConfiguration.php'); From 41cbd7192c8d4d9f400835d5d5ad79311b69d6ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 14 Jul 2025 10:41:30 +0200 Subject: [PATCH 018/118] :sparkles: Add support for apk package manager (on Alpine Linux) --- app/Actions/Server/CheckUpdates.php | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/Actions/Server/CheckUpdates.php b/app/Actions/Server/CheckUpdates.php index f90e007089..e801a1c997 100644 --- a/app/Actions/Server/CheckUpdates.php +++ b/app/Actions/Server/CheckUpdates.php @@ -106,6 +106,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 [ @@ -273,4 +282,32 @@ class CheckUpdates 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, + ]; + } } From e6a838ad50b4197a039d0798d7d78f89daaba4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 14 Jul 2025 11:05:42 +0200 Subject: [PATCH 019/118] :zap: Also support upgrade packages --- app/Actions/Server/UpdatePackage.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Actions/Server/UpdatePackage.php b/app/Actions/Server/UpdatePackage.php index ab0ca94943..d77d5b87ee 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 '.$package; + break; default: return [ 'error' => 'OS not supported', From 7cf713848294fb11703995ffcc1890a3dba8cef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 14 Jul 2025 11:16:44 +0200 Subject: [PATCH 020/118] :sparkles: Add support to install Docker --- app/Actions/Server/InstallDocker.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2e08ec6ad9..2f01ecee2f 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -79,6 +79,15 @@ 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([ + "echo 'Installing Prerequisites...'", + 'zypper update -y', + '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', + ]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } From 57401630a4ef3a52f2cb6fc9e5867424316bf80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Tue, 15 Jul 2025 17:33:58 +0200 Subject: [PATCH 021/118] :pencil2: Fix copy-paste issue --- app/Actions/Server/InstallDocker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2f01ecee2f..1b86cf9dd8 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -82,7 +82,7 @@ class InstallDocker } elseif ($supported_os_type->contains('alpine')) { $command = $command->merge([ "echo 'Installing Prerequisites...'", - 'zypper update -y', + 'apk update', 'command -v curl >/dev/null || apk add curl', 'command -v wget >/dev/null || apk add wget', 'command -v git >/dev/null || apk add git', From 1bfde7d7c6d77f9d334950e164bd32f6050337db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Tue, 15 Jul 2025 17:34:22 +0200 Subject: [PATCH 022/118] :memo: Update template --- resources/views/livewire/server/security/patches.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/server/security/patches.blade.php b/resources/views/livewire/server/security/patches.blade.php index 6a88d8d405..07a7964cbe 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, and zypper. Weekly status + notifications can be managed from notification settings. From 7ec1c939bfe51a8a05d2a565a2cca297c0fb3682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Mon, 22 Dec 2025 11:52:12 +0100 Subject: [PATCH 023/118] :sparkles: Install docker on alpine --- app/Actions/Server/InstallDocker.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 1b86cf9dd8..2256f186e0 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -80,14 +80,7 @@ class InstallDocker } elseif ($supported_os_type->contains('arch')) { $command = $command->merge([$this->getArchDockerInstallCommand()]); } elseif ($supported_os_type->contains('alpine')) { - $command = $command->merge([ - "echo 'Installing Prerequisites...'", - 'apk update', - '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', - ]); + $command = $command->merge([$this->getAlpineDockerInstallCommand()]); } else { $command = $command->merge([$this->getGenericDockerInstallCommand()]); } @@ -163,6 +156,13 @@ class InstallDocker 'systemctl start docker.service'; } + private function getAlpineDockerInstallCommand(): string + { + return "echo 'Installing Prerequisites...' && ". + 'apk update && '. + 'apk add docker'; + } + private function getGenericDockerInstallCommand(): string { return 'curl -fsSL https://get.docker.com | sh'; From 47a14765e14cbe2081138f4bf44c612d7e4a07f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20de=20Biolley?= Date: Tue, 11 Aug 2026 16:46:15 +0200 Subject: [PATCH 024/118] :sparkles: Install prerequisites and Docker on Alpine InstallPrerequisites had no alpine branch and threw before Docker install was ever reached. Enable the community repository (commented out on some images) and install curl/wget/git/jq with apk. The Docker install now also pulls docker-cli-compose, which validateDockerCompose() requires, creates /etc/docker since Alpine's docker package does not ship it, and enables/starts the OpenRC service. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GXciegHbJhXbGvVn7mwgbR --- app/Actions/Server/InstallDocker.php | 8 +++++--- app/Actions/Server/InstallPrerequisites.php | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/Actions/Server/InstallDocker.php b/app/Actions/Server/InstallDocker.php index 2256f186e0..f4c5bdf6a5 100644 --- a/app/Actions/Server/InstallDocker.php +++ b/app/Actions/Server/InstallDocker.php @@ -158,9 +158,11 @@ class InstallDocker private function getAlpineDockerInstallCommand(): string { - return "echo 'Installing Prerequisites...' && ". - 'apk update && '. - 'apk add docker'; + return 'apk update && '. + 'apk add docker docker-cli-compose && '. + 'mkdir -p /etc/docker && '. + 'rc-update add docker default && '. + 'service docker start'; } private function getGenericDockerInstallCommand(): string diff --git a/app/Actions/Server/InstallPrerequisites.php b/app/Actions/Server/InstallPrerequisites.php index 84be7f2068..ffb8f1208e 100644 --- a/app/Actions/Server/InstallPrerequisites.php +++ b/app/Actions/Server/InstallPrerequisites.php @@ -53,6 +53,17 @@ class InstallPrerequisites "echo 'Installing Prerequisites for Arch Linux...'", 'pacman -Syu --noconfirm --needed curl wget git jq', ]); + } elseif ($supported_os_type->contains('alpine')) { + // The community repository holds docker and is commented out on some Alpine images + $command = $command->merge([ + "echo 'Installing Prerequisites for Alpine Linux...'", + "sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true", + 'apk update', + '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', + ]); } else { throw new \Exception('Unsupported OS type for prerequisites installation'); } From 49c8f6cb5d1850fd9c49b59f1f06f359ea0172de Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:31:26 +0200 Subject: [PATCH 025/118] fix: cap remote file and task reads after size-check races Read local file volumes with a bounded head instead of cat so a file that grows after the size check cannot be fully slurped into PHP memory. Treat oversized bounded reads as too large and skip binary detection on that placeholder. For scheduled tasks, read one extra byte, then truncate and append the 5MB notice instead of relying on a one-byte peek. --- app/Jobs/ScheduledTaskJob.php | 3 +- app/Models/LocalFileVolume.php | 27 ++++++++++++-- tests/Unit/LocalFileVolumeContentSizeTest.php | 35 +++++++++++++++++++ tests/Unit/RemoteOutputSizeLimitsTest.php | 18 ++++++++-- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index d24f36350b..f7bd5f933d 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -211,8 +211,9 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue private function boundedTaskCommand(string $command): string { $maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES; + $readLimit = $maxOutputBytes + 1; - return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$maxOutputBytes} > \"\$output_file\"; if IFS= read -r -n 1 extra_byte; then printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; + return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code"; } /** diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index 968e6c3d04..6b0124cb97 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -113,9 +113,9 @@ class LocalFileVolume extends BaseModel return; } - $content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $content = $this->readRemoteFileContent($escapedPath, $server); // Check if content contains binary data by looking for null bytes or non-printable characters - if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) { + if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) { $content = self::BINARY_PLACEHOLDER; } $this->content = $content; @@ -136,6 +136,27 @@ class LocalFileVolume extends BaseModel return $size > self::MAX_CONTENT_SIZE; } + /** + * Cap the remote read itself so a file that grows after the size check + * cannot be fully slurped into PHP memory. + */ + protected function readRemoteFileContent(string $escapedPath, $server): string + { + $readLimit = self::MAX_CONTENT_SIZE + 1; + $content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false); + + return self::contentFromBoundedRead($content); + } + + public static function contentFromBoundedRead(?string $content): string + { + if (strlen((string) $content) > self::MAX_CONTENT_SIZE) { + return self::TOO_LARGE_PLACEHOLDER; + } + + return (string) $content; + } + public function deleteStorageOnServer() { if ($this->is_host_file) { @@ -228,7 +249,7 @@ class LocalFileVolume extends BaseModel if ($this->remoteFileExceedsLimit($escapedPath, $server)) { $this->content = self::TOO_LARGE_PLACEHOLDER; } else { - $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); + $this->content = $this->readRemoteFileContent($escapedPath, $server); } $this->is_directory = false; $this->save(); diff --git a/tests/Unit/LocalFileVolumeContentSizeTest.php b/tests/Unit/LocalFileVolumeContentSizeTest.php index 1954539785..1894bf4394 100644 --- a/tests/Unit/LocalFileVolumeContentSizeTest.php +++ b/tests/Unit/LocalFileVolumeContentSizeTest.php @@ -88,3 +88,38 @@ it('marks git-based file volumes as files before refreshing their content', func '/\$fileVolume->is_directory = false;\s+\$fileVolume->save\(\);\s+if \(\$fileVolume->is_based_on_git\) \{/' ); }); + +it('bounds the remote file read itself to prevent a size-check race', function () { + $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $loadStorage = str($source) + ->after('public function loadStorageOnServer()') + ->before('public function deleteStorageOnServer()'); + + expect($loadStorage->value()) + ->toContain('head -c') + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('bounds directory-to-file conflict reads the same way', function () { + $source = file_get_contents(app_path('Models/LocalFileVolume.php')); + $saveStorage = str($source) + ->after('public function saveStorageOnServer()') + ->before('protected function plainMountPath'); + + expect($saveStorage->value()) + ->not->toContain('instant_remote_process(["cat {$escapedPath}"]'); +}); + +it('treats a bounded remote read that exceeds the limit as too large', function () { + $oversized = str_repeat('a', LocalFileVolume::MAX_CONTENT_SIZE + 1); + + expect(LocalFileVolume::contentFromBoundedRead($oversized)) + ->toBe(LocalFileVolume::TOO_LARGE_PLACEHOLDER); +}); + +it('keeps a bounded remote read that fits the limit', function () { + expect(LocalFileVolume::contentFromBoundedRead('hello')) + ->toBe('hello') + ->and(LocalFileVolume::contentFromBoundedRead(null)) + ->toBe(''); +}); diff --git a/tests/Unit/RemoteOutputSizeLimitsTest.php b/tests/Unit/RemoteOutputSizeLimitsTest.php index b8341dd73f..3660e88284 100644 --- a/tests/Unit/RemoteOutputSizeLimitsTest.php +++ b/tests/Unit/RemoteOutputSizeLimitsTest.php @@ -58,7 +58,7 @@ it('bounds scheduled task output before storing or notifying', function () { expect($source) ->toContain('MAX_OUTPUT_SIZE_BYTES') - ->toContain('head -c {$maxOutputBytes}') + ->toContain('head -c {$readLimit}') ->toContain('[... Output truncated at'); $reflection = new ReflectionClass(ScheduledTaskJob::class); @@ -70,7 +70,7 @@ it('bounds scheduled task output before storing or notifying', function () { exec('bash -c '.escapeshellarg($failureCommand).' 2>&1', $failureOutput, $failureExitCode); expect(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES)->toBe(5 * 1024 * 1024) - ->and($command)->toContain('head -c 5242880') + ->and($command)->toContain('head -c 5242881') ->and($exitCode)->toBe(0) ->and($commandExitCode)->toBe(0) ->and(implode("\n", $commandOutput))->toBe('hello') @@ -78,6 +78,20 @@ it('bounds scheduled task output before storing or notifying', function () { ->and(implode("\n", $failureOutput))->toBe('failure'); }); +it('marks scheduled task output that exceeds the limit as truncated', function () { + $reflection = new ReflectionClass(ScheduledTaskJob::class); + $method = $reflection->getMethod('boundedTaskCommand'); + $largeOutputCommand = 'head -c '.(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES + 1).' /dev/zero | tr "\\0" "x"'; + $command = $method->invoke($reflection->newInstanceWithoutConstructor(), $largeOutputCommand); + + exec('bash -c '.escapeshellarg($command), $output, $exitCode); + $taskOutput = implode("\n", $output); + + expect($exitCode)->toBe(0) + ->and(strlen($taskOutput))->toBeGreaterThan(ScheduledTaskJob::MAX_OUTPUT_SIZE_BYTES) + ->and($taskOutput)->toEndWith('[... Output truncated at 5MB limit ...]'); +}); + it('does not pass the scheduled task output wrapper through the sudo rewriter', function () { $source = remoteOutputSource('app/Jobs/ScheduledTaskJob.php'); $reflection = new ReflectionClass(ScheduledTaskJob::class); From 342be9e99b9f9f2ce9d5b00ce3cb6608a375b115 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:52:51 +0200 Subject: [PATCH 026/118] chore: remove horizon var from .env.development --- .env.development.example | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.env.development.example b/.env.development.example index f97b9c65e7..380f10a446 100644 --- a/.env.development.example +++ b/.env.development.example @@ -41,11 +41,6 @@ DB_PORT=5432 VITE_HOST=localhost VITE_PORT=5173 -# Laravel Horizon Admin Access -# Comma-separated list of email addresses allowed to access /horizon dashboard -# The root user (User ID 0) always has access -# HORIZON_ALLOWED_EMAILS=admin@example.com,devops@example.com - # Enable Laravel Nightwatch monitoring NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= From 5da6c5e11822270212b2b558fc6839452f803b86 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:07:49 +0200 Subject: [PATCH 027/118] feat(horizon): read allowed emails from config and allow root user by id - read allowed emails from config instead of env(), which returns null once configs are cached in production - allow the root user by id instead of matching their email - clean up gate code --- app/Providers/HorizonServiceProvider.php | 23 +++++++++-------------- config/horizon.php | 12 ++++++++++++ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 1978ea0cb4..9fe33b2cdd 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -75,21 +75,16 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider protected function gate(): void { - Gate::define('viewHorizon', function ($user) { - $root_user = User::find(0); + Gate::define('viewHorizon', function (User $user) { + if ($user->id === 0) { + return true; + } - // Get additional allowed emails from environment variable - $allowedEmails = array_filter( - array_map('trim', explode(',', env('HORIZON_ALLOWED_EMAILS', ''))) - ); - - // Merge root user email with additional allowed emails - $authorizedEmails = array_merge( - [$root_user->email], - $allowedEmails - ); - - return in_array($user->email, $authorizedEmails); + return str(config()->string('horizon.allowed_emails')) + ->lower() + ->explode(',') + ->map(fn (string $email) => trim($email)) + ->contains($user->email); }); } } diff --git a/config/horizon.php b/config/horizon.php index d178038496..d86c52affe 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -30,6 +30,18 @@ return [ 'path' => env('HORIZON_PATH', 'horizon'), + /* + |-------------------------------------------------------------------------- + | Horizon Allowed Emails + |-------------------------------------------------------------------------- + | + | A comma-separated list of email addresses that may access the Horizon + | dashboard in addition to the root user. + | + */ + + 'allowed_emails' => env('HORIZON_ALLOWED_EMAILS', ''), + /* |-------------------------------------------------------------------------- | Horizon Redis Connection From c2b7978e3ee01baeef58632f9c32c94e7c5a7d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Far=C3=A8s=20SIONI?= <82713835+fsioni@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:21:49 +0200 Subject: [PATCH 028/118] fix(ui): improve highlighted button and spinner contrast (#11279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 🏔️ Peak <122374094+peaklabs-dev@users.noreply.github.com> --- resources/css/app.css | 10 ++++++---- resources/css/utilities.css | 2 +- resources/views/components/unsaved-bar.blade.php | 2 +- tests/Feature/HighlightedButtonStylingTest.php | 15 ++++++++++++++- tests/Feature/SentinelUnsavedBarFlashTest.php | 6 +++--- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/resources/css/app.css b/resources/css/app.css index 636cf2ce8a..9dc39b7cc5 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -398,9 +398,12 @@ html[data-theme="custom"] .animate-spin { color: var(--theme-bright-color) !important; } -/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control. */ +/* Opt out of the brand spinner when the surrounding surface is a selected/neutral control + or a highlighted button, whose accent surface would camouflage a brand-colored spinner. */ .dark .animate-spin.spinner-current, -html[data-theme="custom"] .animate-spin.spinner-current { +html[data-theme="custom"] .animate-spin.spinner-current, +html[data-theme="custom"] .button-highlighted .animate-spin, +html[data-theme="custom"] button[isHighlighted] .animate-spin { color: inherit !important; } @@ -993,8 +996,7 @@ html[data-theme="custom"] { } html[data-theme="custom"] .control-selected, -html[data-theme="custom"] .logs-viewer-btn-active, -html[data-theme="custom"] .button-highlighted:hover { +html[data-theme="custom"] .logs-viewer-btn-active { color: var(--color-accent-foreground); } diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 44218b0f3c..7c690cba75 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -131,7 +131,7 @@ } @utility button-highlighted { - @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!; + @apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!; } @utility control-selected { diff --git a/resources/views/components/unsaved-bar.blade.php b/resources/views/components/unsaved-bar.blade.php index 1a5f85d3a0..8d029c2fb3 100644 --- a/resources/views/components/unsaved-bar.blade.php +++ b/resources/views/components/unsaved-bar.blade.php @@ -67,7 +67,7 @@ class="button-highlighted flex h-8 items-center gap-2 rounded-lg px-4 text-[13px] font-semibold transition-[transform,background-color] active:scale-[0.98]"> Save changes Enter + class="rounded border border-current/20 bg-current/10 px-1.5 py-0.5 text-[10px] leading-none font-medium text-current">Enter
diff --git a/tests/Feature/HighlightedButtonStylingTest.php b/tests/Feature/HighlightedButtonStylingTest.php index eae11deaff..834d792cee 100644 --- a/tests/Feature/HighlightedButtonStylingTest.php +++ b/tests/Feature/HighlightedButtonStylingTest.php @@ -10,10 +10,23 @@ test('highlighted buttons use the shared coollabs style in every color scheme', expect($utilities) ->toContain('@utility button-highlighted') - ->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-white! hover:from-coollabs-100 hover:to-coollabs hover:text-white!;') + ->toContain('@apply border-coollabs-200 bg-linear-to-b from-coollabs-100 to-coollabs-200 text-accent-foreground! hover:from-coollabs-100 hover:to-coollabs hover:text-accent-foreground!;') ->and($appStyles) ->toContain('button[isHighlighted]:not(:disabled)') ->toContain('@apply button-highlighted;') ->and($views) ->not->toContain('dark:bg-warning/15! dark:text-warning! dark:ring-warning/25'); }); + +test('custom theme highlighted buttons use the computed contrasting foreground', function () { + $utilities = file_get_contents(resource_path('css/utilities.css')); + $appStyles = file_get_contents(resource_path('css/app.css')); + + expect($utilities) + ->toContain('text-accent-foreground!') + ->toContain('hover:text-accent-foreground!') + ->not->toContain('text-white! hover:') + ->and($appStyles) + ->toContain('html[data-theme="custom"] .button-highlighted .animate-spin') + ->toContain('html[data-theme="custom"] button[isHighlighted] .animate-spin'); +}); diff --git a/tests/Feature/SentinelUnsavedBarFlashTest.php b/tests/Feature/SentinelUnsavedBarFlashTest.php index ac8f72ddf0..0c5d196c03 100644 --- a/tests/Feature/SentinelUnsavedBarFlashTest.php +++ b/tests/Feature/SentinelUnsavedBarFlashTest.php @@ -76,9 +76,9 @@ test('unsaved bar keyboard shortcut remains visible in light mode', function () $contents = file_get_contents(resource_path('views/components/unsaved-bar.blade.php')); expect($contents) - ->toContain('border-coollabs/20 bg-coollabs/10') - ->toContain('text-coollabs-200') - ->toContain('dark:border-white/20 dark:bg-white/10 dark:text-white/75'); + ->toContain('border-current/20 bg-current/10') + ->toContain('text-current') + ->not->toContain('text-coollabs-200'); }); test('unsaved bar uses a light surface in light mode', function () { From 264cb8e2b01dc8087f0bc1e21dbe9a0f97fbba36 Mon Sep 17 00:00:00 2001 From: Andrew Matia <73502748+drewmt@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:24:06 +0300 Subject: [PATCH 029/118] fix(webhook): restore GitLab source deployments (#11284) --- app/Http/Controllers/Webhook/Gitlab.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index e521093d7e..c9a554e2a5 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -15,6 +15,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use Visus\Cuid2\Cuid2; class Gitlab extends Controller { From d2d6c8ac967a9c7e8b68cf470a445333b52a49cf Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:51:54 +0200 Subject: [PATCH 030/118] fix(deployments): defer container cleanup and configure dashboard HTTPS --- app/Jobs/ApplicationDeploymentJob.php | 40 +++++++- app/Jobs/RemoveContainerJob.php | 49 ++++++++++ app/Livewire/Settings/Index.php | 5 + app/Models/InstanceSettings.php | 6 ++ app/Models/Server.php | 25 ++++- bootstrap/helpers/docker.php | 9 ++ ...tps_enabled_to_instance_settings_table.php | 28 ++++++ .../views/livewire/settings/index.blade.php | 17 +++- tests/Feature/DashboardHttpsRedirectTest.php | 94 +++++++++++++++++++ tests/Unit/DockerRemoveWithTimeoutTest.php | 78 +++++++++++++++ 10 files changed, 341 insertions(+), 10 deletions(-) create mode 100644 app/Jobs/RemoveContainerJob.php create mode 100644 database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php create mode 100644 tests/Feature/DashboardHttpsRedirectTest.php create mode 100644 tests/Unit/DockerRemoveWithTimeoutTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 3047bcc01a..1e8450c1b9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; + private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; + private const DOCKER_CLIENT_ENV_KEYS = [ 'BUILDKIT_HOST', 'BUILDX_BUILDER', @@ -3977,15 +3979,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ); } else { $this->execute_remote_command( - [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true], - ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] + [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true] ); + $this->removeContainerWithTimeout($containerName); } } catch (Exception $error) { $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); } } + private function removeContainerWithTimeout(string $containerName): void + { + $outputKey = 'container_remove_'.md5($containerName); + + $this->execute_remote_command([ + dockerRemoveCommandWithTimeout($containerName), + 'hidden' => true, + 'ignore_errors' => true, + 'save' => $outputKey, + 'append' => false, + ]); + + if (! isset($this->saved_outputs)) { + return; + } + + $output = (string) $this->saved_outputs->get($outputKey, ''); + if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) { + return; + } + + $this->application_deployment_queue->addLogEntry( + "Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.", + 'stderr' + ); + + RemoveContainerJob::dispatch($this->server->id, $containerName) + ->delay(now()->addMinutes(5)); + } + private function stop_running_container(bool $force = false) { try { @@ -5016,9 +5048,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // do not remove already running container for PR deployments } else { $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); - $this->execute_remote_command( - ["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true] - ); + $this->removeContainerWithTimeout($this->container_name); } } } diff --git a/app/Jobs/RemoveContainerJob.php b/app/Jobs/RemoveContainerJob.php new file mode 100644 index 0000000000..de21603248 --- /dev/null +++ b/app/Jobs/RemoveContainerJob.php @@ -0,0 +1,49 @@ +serverId); + + instant_remote_process( + [dockerRemoveCommandWithTimeout($this->containerName)], + $server, + timeout: 75, + disableMultiplexing: true, + ); + } + + public function backoff(): array + { + return [300, 900]; + } + + public function failed(?\Throwable $exception): void + { + Log::warning('Deferred container removal failed', [ + 'server_id' => $this->serverId, + 'container' => $this->containerName, + 'error' => $exception?->getMessage(), + ]); + } +} diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php index 91bc03d214..40705617f1 100644 --- a/app/Livewire/Settings/Index.php +++ b/app/Livewire/Settings/Index.php @@ -20,6 +20,9 @@ class Index extends Component #[Validate('nullable|string|max:255|url')] public ?string $fqdn = null; + #[Validate('boolean')] + public bool $is_dashboard_force_https_enabled = true; + #[Validate('required|integer|min:1025|max:65535')] public int $public_port_min; @@ -68,6 +71,7 @@ class Index extends Component $this->server = Server::findOrFail(0); } $this->fqdn = $this->settings->fqdn; + $this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled; $this->public_port_min = $this->settings->public_port_min; $this->public_port_max = $this->settings->public_port_max; $this->instance_name = $this->settings->instance_name; @@ -91,6 +95,7 @@ class Index extends Component $this->authorize('update', $this->settings); $this->validate(); $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; + $this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled; $this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_max = $this->public_port_max; $this->settings->instance_name = $this->instance_name; diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 877fc5b12f..adf5a4abdc 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -9,6 +9,10 @@ use Spatie\Url\Url; class InstanceSettings extends Model { + protected $attributes = [ + 'is_dashboard_force_https_enabled' => true, + ]; + protected $fillable = [ 'public_ipv4', 'public_ipv6', @@ -51,6 +55,7 @@ class InstanceSettings extends Model 'webhook_allow_localhost', 'avatar_storage_type', 'avatar_s3_storage_id', + 'is_dashboard_force_https_enabled', ]; protected $hidden = [ @@ -89,6 +94,7 @@ class InstanceSettings extends Model 'is_mcp_server_enabled' => 'boolean', 'webhook_allowed_internal_hosts' => 'array', 'webhook_allow_localhost' => 'boolean', + 'is_dashboard_force_https_enabled' => 'boolean', ]; protected static function booted(): void diff --git a/app/Models/Server.php b/app/Models/Server.php index 0f5a35364c..b5b11d26a5 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -731,9 +731,7 @@ class Server extends BaseModel ]; if ($schema === 'https') { - $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [ - 0 => 'redirect-to-https', - ]; + $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings); $traefik_dynamic_conf['http']['routers']['coolify-https'] = [ 'middlewares' => [ @@ -792,8 +790,9 @@ class Server extends BaseModel $url = Url::fromString($settings->fqdn); $host = $url->getHost(); $schema = $url->getScheme(); + $siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host); $caddy_file = " -$schema://$host { +$siteAddress { encode zstd gzip handle /app/* { reverse_proxy coolify-realtime:6001 @@ -819,6 +818,24 @@ $schema://$host { ], $this); } + public function dashboardHttpMiddlewares(InstanceSettings $settings): array + { + if ($settings->is_dashboard_force_https_enabled) { + return ['redirect-to-https']; + } + + return ['gzip']; + } + + public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string + { + if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) { + return "http://{$host}, https://{$host}"; + } + + return "{$schema}://{$host}"; + } + public function proxyPath() { $base_path = config('constants.coolify.base_config_path'); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index ae29637554..a60ba675be 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -263,6 +263,15 @@ function dockerStopCommand(int $timeout, string $containers, Server|string|null return $command; } + +function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string +{ + $container = escapeShellValue($container); + $script = "if command -v timeout >/dev/null 2>&1; then timeout -k {$killAfter}s {$timeout}s docker rm -f {$container}; exit_code=\$?; else exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; fi; exit \$exit_code"; + + return 'bash -c '.escapeShellValue($script); +} + function escapeShellValue(string $value): string { return "'".str_replace("'", "'\\''", $value)."'"; diff --git a/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php b/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php new file mode 100644 index 0000000000..d88f9be3fc --- /dev/null +++ b/database/migrations/2026_08_18_104130_add_is_dashboard_force_https_enabled_to_instance_settings_table.php @@ -0,0 +1,28 @@ +boolean('is_dashboard_force_https_enabled')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('instance_settings', function (Blueprint $table) { + $table->dropColumn('is_dashboard_force_https_enabled'); + }); + } +}; diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 7992b4ca9f..518f1a45dc 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -11,12 +11,27 @@ targets="fqdn,instance_name,public_ipv4,public_ipv6,dev_helper_version" />
-
+
!str_starts_with(strtolower($fqdn ?? ''), 'https://'), + ])>
+ @if (str_starts_with(strtolower($fqdn ?? ''), 'https://')) +
+ +
+ @endif + diff --git a/tests/Feature/DashboardHttpsRedirectTest.php b/tests/Feature/DashboardHttpsRedirectTest.php new file mode 100644 index 0000000000..d709b84289 --- /dev/null +++ b/tests/Feature/DashboardHttpsRedirectTest.php @@ -0,0 +1,94 @@ +is_dashboard_force_https_enabled)->toBeTrue() + ->and($server->dashboardHttpMiddlewares($settings))->toBe(['redirect-to-https']) + ->and($server->dashboardCaddySiteAddress($settings, 'https', 'dashboard.example.com')) + ->toBe('https://dashboard.example.com'); +}); + +test('dashboard proxy accepts HTTP and HTTPS when its redirect is disabled', function () { + $settings = new InstanceSettings(['is_dashboard_force_https_enabled' => false]); + $server = new Server; + + expect($server->dashboardHttpMiddlewares($settings))->toBe(['gzip']) + ->and($server->dashboardCaddySiteAddress($settings, 'https', 'dashboard.example.com')) + ->toBe('http://dashboard.example.com, https://dashboard.example.com') + ->and($server->dashboardCaddySiteAddress($settings, 'http', 'dashboard.example.com')) + ->toBe('http://dashboard.example.com'); +}); + +test('instance administrators can configure the dashboard HTTPS redirect', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + $settings = InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => 'https://dashboard.example.com', + ]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Index::class) + ->assertSet('is_dashboard_force_https_enabled', true) + ->assertSee('Redirect HTTP to HTTPS') + ->assertSee('Keep enabled when Cloudflare uses Full or Full (Strict) SSL.') + ->set('is_dashboard_force_https_enabled', false) + ->call('instantSave') + ->assertHasNoErrors(); + + expect($settings->fresh()->is_dashboard_force_https_enabled)->toBeFalse(); +}); + +test('dashboard HTTPS redirect saves immediately when changed', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php')); + + expect($contents) + ->toMatch('/id="is_dashboard_force_https_enabled"[\s\S]*?onChange="submit"/') + ->and($contents)->not->toContain('targets="fqdn,is_dashboard_force_https_enabled'); +}); + +test('dashboard HTTPS redirect is next to the URL on desktop', function () { + $contents = file_get_contents(resource_path('views/livewire/settings/index.blade.php')); + + expect($contents) + ->toContain("'lg:col-span-2' => !str_starts_with") + ->not->toContain('
'); +}); + +test('dashboard HTTPS redirect control is hidden for an HTTP URL', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]); + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => 'http://dashboard.example.com', + ]); + Once::flush(); + + $user = User::factory()->create(); + $rootTeam->members()->attach($user->id, ['role' => 'admin']); + + $this->actingAs($user); + session(['currentTeam' => ['id' => $rootTeam->id]]); + + Livewire::test(Index::class) + ->assertDontSee('Redirect HTTP to HTTPS'); +}); diff --git a/tests/Unit/DockerRemoveWithTimeoutTest.php b/tests/Unit/DockerRemoveWithTimeoutTest.php new file mode 100644 index 0000000000..a3b9f15dc5 --- /dev/null +++ b/tests/Unit/DockerRemoveWithTimeoutTest.php @@ -0,0 +1,78 @@ +toStartWith("bash -c '") + ->toContain('timeout -k 10s 60s docker rm -f') + ->toContain('__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'); +}); + +it('uses timeout syntax supported by coreutils and busybox', function () { + expect(dockerRemoveCommandWithTimeout('container-name')) + ->toContain('command -v timeout') + ->toContain('timeout -k 10s 60s') + ->not->toContain('--kill-after'); +}); + +it('preserves bounded cleanup when commands are adapted for non-root servers', function () { + $server = Mockery::mock(Server::class)->makePartial(); + $server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); + $server->shouldReceive('setAttribute')->andReturnSelf(); + $server->user = 'ubuntu'; + + $commands = parseCommandsByLineForSudo( + collect([dockerRemoveCommandWithTimeout('container-name')]), + $server + ); + $command = $commands[0]; + + expect($command) + ->toStartWith("sudo bash -c '") + ->toContain('timeout -k 10s 60s docker rm -f') + ->toContain('__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'); +}); + +it('escapes container names in bounded removal commands', function () { + $containerName = "container'; reboot; '"; + $directory = sys_get_temp_dir().'/coolify-docker-remove-'.bin2hex(random_bytes(4)); + $captureFile = $directory.'/arguments'; + mkdir($directory); + file_put_contents($directory.'/docker', "#!/bin/sh\nprintf '%s\\n' \"\$@\" > \"\$CAPTURE_FILE\"\n"); + chmod($directory.'/docker', 0755); + + $process = new Process(['/bin/sh', '-c', dockerRemoveCommandWithTimeout($containerName)], env: [ + 'PATH' => $directory.':'.getenv('PATH'), + 'CAPTURE_FILE' => $captureFile, + ]); + $process->run(); + + expect($process->isSuccessful())->toBeTrue() + ->and(file_get_contents($captureFile))->toBe("rm\n-f\n{$containerName}\n"); + + unlink($captureFile); + unlink($directory.'/docker'); + rmdir($directory); +}); + +it('configures deferred removal attempts to outlive the shell timeout', function () { + $job = new RemoveContainerJob(123, 'container-name'); + + expect($job->serverId)->toBe(123) + ->and($job->containerName)->toBe('container-name') + ->and($job->timeout)->toBeGreaterThan(60) + ->and($job->tries)->toBeGreaterThan(1); +}); + +it('continues deployments and schedules deferred cleanup after a removal timeout', function () { + $source = file_get_contents(dirname(__DIR__, 2).'/app/Jobs/ApplicationDeploymentJob.php'); + + expect($source) + ->toContain('dockerRemoveCommandWithTimeout($containerName)') + ->toContain('timed out after 60 seconds. The deployment will continue') + ->toContain('RemoveContainerJob::dispatch($this->server->id, $containerName)') + ->toContain('->delay(now()->addMinutes(5))'); +}); From 3cca2a0bfe184344b17fc650f2f1fa54ab7e5e8e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:53:41 +0200 Subject: [PATCH 031/118] fix(ui): add spacing between database backup sections Add a flex column gap to the backup edit layout and cover the spacing with a feature test. --- .../views/livewire/project/database/backup-edit.blade.php | 2 +- tests/Feature/DatabaseBackupsLayoutTest.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index 31637d7f9d..d85d506a0b 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -1,4 +1,4 @@ -
+
@if ($backup->database_id === 0) @include('livewire.project.database.backup-edit.general') @include('livewire.project.database.backup-edit.s3') diff --git a/tests/Feature/DatabaseBackupsLayoutTest.php b/tests/Feature/DatabaseBackupsLayoutTest.php index b2ab349d97..2b4941c091 100644 --- a/tests/Feature/DatabaseBackupsLayoutTest.php +++ b/tests/Feature/DatabaseBackupsLayoutTest.php @@ -50,3 +50,10 @@ it('keeps backup execution actions in the normal table flow', function () { expect($executions) ->not->toContain('sticky right-0'); }); + +it('spaces instance backup settings sections', function () { + $edit = file_get_contents(resource_path('views/livewire/project/database/backup-edit.blade.php')); + + expect($edit) + ->toStartWith('
'); +}); From 861a9049920cf227ef3dd8bbf3e308850225833c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:53:40 +0200 Subject: [PATCH 032/118] fix(server): surface pending proxy and outdated Traefik states --- app/Jobs/CheckTraefikVersionForServerJob.php | 8 +- app/Livewire/ActivityMonitor.php | 15 +++- app/Livewire/Server/Navbar.php | 19 +++- app/Livewire/Server/Proxy.php | 6 +- app/Models/Server.php | 27 ++++++ resources/css/utilities.css | 3 +- .../proxy-configuration-warning.blade.php | 37 ++++++++ .../views/components/server/sidebar.blade.php | 20 ++++- .../views/livewire/server/index.blade.php | 39 +++++--- .../views/livewire/server/navbar.blade.php | 16 +++- .../views/livewire/server/proxy.blade.php | 15 +++- .../Feature/ActivityMonitorCrossTeamTest.php | 20 +++++ tests/Feature/ButtonHeightConsistencyTest.php | 4 +- .../DashboardServerMetricsChartTest.php | 19 ++++ tests/Feature/Proxy/RestartProxyTest.php | 59 ++++++++++++ .../ProxyConfigurationLoadingStateTest.php | 12 +++ tests/Feature/ServerSidebarIconsTest.php | 14 +++ tests/Feature/TraefikVersionStateTest.php | 89 +++++++++++++++++++ 18 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 resources/views/components/proxy-configuration-warning.blade.php create mode 100644 tests/Feature/ProxyConfigurationLoadingStateTest.php create mode 100644 tests/Feature/TraefikVersionStateTest.php diff --git a/app/Jobs/CheckTraefikVersionForServerJob.php b/app/Jobs/CheckTraefikVersionForServerJob.php index 91869eb12d..054a739bc6 100644 --- a/app/Jobs/CheckTraefikVersionForServerJob.php +++ b/app/Jobs/CheckTraefikVersionForServerJob.php @@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue */ public function handle(): void { + $this->clearOutdatedInfo(); + // Detect current version (makes SSH call) $currentVersion = getTraefikVersionFromDockerCompose($this->server); - // Update detected version in database $this->server->update(['detected_traefik_version' => $currentVersion]); if (! $currentVersion) { @@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue ProxyStatusChangedUI::dispatch($this->server->team_id); } + private function clearOutdatedInfo(): void + { + $this->server->update(['traefik_outdated_info' => null]); + } + /** * Get information about newer branches if available. */ diff --git a/app/Livewire/ActivityMonitor.php b/app/Livewire/ActivityMonitor.php index 665d14ba0e..25935d88e2 100644 --- a/app/Livewire/ActivityMonitor.php +++ b/app/Livewire/ActivityMonitor.php @@ -29,7 +29,10 @@ class ActivityMonitor extends Component public static $eventDispatched = false; - protected $listeners = ['activityMonitor' => 'newMonitorActivity']; + protected $listeners = [ + 'activityMonitor' => 'newMonitorActivity', + 'processDialogClosed' => 'clearActivity', + ]; public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null) { @@ -50,6 +53,16 @@ class ActivityMonitor extends Component $this->isPollingActive = true; } + public function clearActivity(): void + { + $this->activityId = null; + $this->activity = null; + $this->isPollingActive = false; + $this->eventToDispatch = 'activityFinished'; + $this->eventData = null; + self::$eventDispatched = false; + } + public function hydrateActivity() { if ($this->activityId === null) { diff --git a/app/Livewire/Server/Navbar.php b/app/Livewire/Server/Navbar.php index 31a8578657..d9f70ea253 100644 --- a/app/Livewire/Server/Navbar.php +++ b/app/Livewire/Server/Navbar.php @@ -163,6 +163,7 @@ class Navbar extends Component $previousStatus = $this->proxyStatus; $this->server->refresh(); $this->proxyStatus = $this->server->proxy->status ?? 'unknown'; + $this->dispatchProxyConfigurationState(); // If event contains activityId, open activity monitor if ($event && isset($event['activityId'])) { @@ -227,6 +228,16 @@ class Navbar extends Component { $this->server->refresh(); $this->server->load('settings'); + $this->dispatchProxyConfigurationState(); + } + + private function dispatchProxyConfigurationState(): void + { + $this->dispatch( + 'proxy-configuration-state-changed', + pending: $this->server->hasPendingProxyConfiguration(), + traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(), + ); } public function refreshSentinelStatus($event = null): void @@ -248,10 +259,12 @@ class Navbar extends Component return false; } - // Check if server has outdated info stored - $outdatedInfo = $this->server->traefik_outdated_info; + return $this->server->hasCurrentTraefikOutdatedInfo(); + } - return ! empty($outdatedInfo) && isset($outdatedInfo['type']); + public function getHasPendingProxyConfigurationProperty(): bool + { + return $this->server->hasPendingProxyConfiguration(); } public function render() diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 8cd4e96405..811a01eb19 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -161,6 +161,7 @@ class Proxy extends Component $this->server->proxy->redirect_url = $this->redirectUrl; $this->server->save(); $this->server->setupDefaultRedirect(); + $this->dispatch('refreshServerShow'); $this->dispatch('success', 'Proxy configuration saved.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -175,6 +176,7 @@ class Proxy extends Component $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); SaveProxyConfiguration::run($this->server, $this->proxySettings); $this->server->save(); + $this->dispatch('refreshServerShow'); $this->dispatch('success', 'Proxy configuration reset to default.'); } catch (\Throwable $e) { return handleError($e, $this); @@ -276,7 +278,9 @@ class Proxy extends Component // Check if we have outdated info stored for this server (faster than computing) $outdatedInfo = $this->server->traefik_outdated_info; - if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') { + $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v'); + $detectedCurrentVersion = ltrim($currentVersion, 'v'); + if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') { // Use the upgrade_target field if available (e.g., "v3.6") if (isset($outdatedInfo['upgrade_target'])) { return str_starts_with($outdatedInfo['upgrade_target'], 'v') diff --git a/app/Models/Server.php b/app/Models/Server.php index b5b11d26a5..f7a4bf20c0 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -858,6 +858,33 @@ $siteAddress { return data_get($this->proxy, 'type'); } + public function hasPendingProxyConfiguration(): bool + { + if ($this->proxy->get('status') !== 'running') { + return false; + } + + $savedSettings = $this->proxy->get('last_saved_settings'); + $appliedSettings = $this->proxy->get('last_applied_settings'); + + return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings; + } + + public function hasCurrentTraefikOutdatedInfo(): bool + { + if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) { + return false; + } + + $detectedVersion = ltrim((string) $this->detected_traefik_version, 'v'); + $storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v'); + $type = data_get($this->traefik_outdated_info, 'type'); + + return filled($detectedVersion) + && $storedVersion === $detectedVersion + && in_array($type, ['patch_update', 'minor_upgrade'], true); + } + public function scopeWithProxy(): Builder { return $this->proxy->modelScope(); diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 7c690cba75..6fdd260b5f 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -126,8 +126,7 @@ } @utility button { - /* h-9 matches input-select; nowrap + shrink-0 keep side-by-side action rows equal height */ - @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-9 min-h-9 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; + @apply inline-flex shrink-0 gap-1.5 justify-center items-center whitespace-nowrap px-2.5 h-8 min-h-8 text-[13px] text-black normal-case rounded-md border outline-0 cursor-pointer font-medium transition-colors bg-white border-neutral-200 hover:bg-neutral-100 dark:bg-white/[0.06] dark:text-fg dark:hover:text-fg dark:hover:bg-white/[0.1] dark:border-white/[0.08] hover:text-black disabled:cursor-not-allowed min-w-fit dark:disabled:text-fg-faint disabled:border-neutral-200 dark:disabled:border-white/[0.06] disabled:hover:bg-transparent disabled:bg-transparent disabled:text-neutral-300 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent; } @utility button-highlighted { diff --git a/resources/views/components/proxy-configuration-warning.blade.php b/resources/views/components/proxy-configuration-warning.blade.php new file mode 100644 index 0000000000..7337779d0e --- /dev/null +++ b/resources/views/components/proxy-configuration-warning.blade.php @@ -0,0 +1,37 @@ +@props(['canRestart' => false]) + +
+ + + +
diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 006efb2cac..41cbd7ce9d 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -55,6 +55,8 @@ 'icon' => 'network', 'group' => 'Platform', 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, + 'warning' => $server->hasCurrentTraefikOutdatedInfo(), + 'tracks_proxy_configuration' => true, 'children' => [ ['label' => 'Configuration', 'route' => 'server.proxy', 'active' => $activeSubMenu === 'configuration', 'icon' => 'settings'], ['label' => 'Dynamic Configurations', 'route' => 'server.proxy.dynamic-confs', 'active' => $activeSubMenu === 'dynamic-confs', 'icon' => 'sliders', 'visible' => $server->proxySet()], @@ -167,7 +169,15 @@ $groupedServerMenuItems = $serverMenuItems->groupBy('group'); @endphp -