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 01/11] 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 cddf66389..c186c8042 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 000000000..e4a2ba0df --- /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 000000000..d17061c51 --- /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 000000000..0d35ff45c --- /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 000000000..98dc6d4c0 --- /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 000000000..645130e01 --- /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 000000000..3faef77b2 --- /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 000000000..65a476d45 --- /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 4038fe63e..93d27615a 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 ab3884320..845c25a54 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -163,6 +163,30 @@ public function instantSaveDiscordEnabled() } } + 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 724dd0bac..88160cedd 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -240,29 +240,57 @@ public function instantSave(?string $type = null) } } + 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 @@ public function submitResend() { 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 @@ public function submitResend() } } + 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 d79eea87b..88d80126c 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -153,6 +153,34 @@ public function instantSavePushoverEnabled() } } + 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 f870b3986..7fee4e32e 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -147,6 +147,32 @@ public function instantSaveSlackEnabled() } } + 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 fc3966cf6..ebb49e0a9 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -246,6 +246,34 @@ public function instantSaveTelegramEnabled() } } + 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 630d422a9..1f79eb004 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -141,6 +141,30 @@ public function instantSaveWebhookEnabled() } } + 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 4a419a12f..de7c26807 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 @@ public function submit() public function requestEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + // For self-hosted, check if email is enabled if (! isCloud()) { $settings = instanceSettings(); @@ -122,6 +134,10 @@ public function requestEmailChange() public function verifyEmailChange() { try { + if ($this->rejectSsoEmailChange()) { + return; + } + $this->validate([ 'email_verification_code' => ['required', 'string', 'size:6'], ]); @@ -178,6 +194,10 @@ public function verifyEmailChange() 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 @@ public function cancelEmailChange() 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 @@ public function resetPassword() } } + 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 5d77f4998..18d07ea90 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,6 +177,39 @@ public function instantSave() } } + 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 @@ public function render() { 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 3a6237183..8b8e93f5f 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 @@ public function rules() { 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 @@ public function mount() $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 @@ public function instantSave() { 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 8c0e24400..9e0093605 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -138,28 +138,54 @@ public function instantSave(string $type) } } + 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 @@ public function submitSmtp() 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 @@ public function submitResend() } } + 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 6f949b716..24eddfaf9 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 @@ private function updateOauthSettings(?string $provider = null) 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 @@ public function instantSave(string $provider) } } - 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 d5c3bfa28..65fcd1486 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 @@ protected static function booted(): void }); } + 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 000000000..1edf71ad2 --- /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 08e08d85b..25e38b4d1 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 @@ public function couldBeEnabled(): bool 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 9cbe88835..5fb65f4f3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,6 +11,7 @@ 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 @@ public function hasEmailChangeRequest(): bool && 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 717daf2a2..9858289be 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\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 @@ public function boot(): void $this->configurePasswords(); $this->configureSanctumModel(); $this->configureGitHubHttp(); - + $this->configureOidcSocialite(); } private function configureCommands(): void @@ -63,6 +67,24 @@ private function configureSanctumModel(): void 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 85f38b967..d65be87eb 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ 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 @@ public function boot(): void $isFirstUser = User::count() === 0; $settings = instanceSettings(); - if (! $settings->is_registration_enabled) { + if (! $settings->isPasswordRegistrationAllowed()) { return redirect()->route('login'); } @@ -60,13 +61,13 @@ public function boot(): void $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 @@ public function boot(): void $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 000000000..feb36211c --- /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 fd3fbe74b..f177e6c16 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 6a21cda18..6ce6290ce 100644 --- a/config/services.php +++ b/config/services.php @@ -60,6 +60,14 @@ '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 123fd226d..1a620c78e 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 @@ */ 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 000000000..3160ef9dd --- /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 000000000..9f838e577 --- /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 000000000..06c0f1dd5 --- /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 2e5e6fcc4..3edf40bde 100644 --- a/database/seeders/OauthSettingSeeder.php +++ b/database/seeders/OauthSettingSeeder.php @@ -22,6 +22,7 @@ public function run(): void 'github', 'gitlab', 'google', + 'oidc', 'authentik', 'infomaniak', 'zitadel', diff --git a/lang/de.json b/lang/de.json index fd587de22..9193a05e4 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 a81e1ee68..3e587763d 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 bcd8e2393..b05437ac4 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 ede49117a..d59834145 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -90,7 +90,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d @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 565e485d0..6e6ed5d20 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 0a68a9155..8308fbc44 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 0e5406c78..f665a87fa 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 71a9f0680..e105f5fe4 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 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl Save -
-
- + @if ($smtpEnabled) + + Disable SMTP Server + + @else + + Enable SMTP Server + + @endif
@@ -95,10 +96,15 @@ class="p-4 border dark:border-coolgray-300 border-neutral-200 rounded-lg flex fl 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 74cd9e8d2..f633b7b33 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 f87a13c37..fb7b1bc40 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 93abd628c..eb9cb1b6b 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 7650a5654..82ad9d581 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 544ed7d4c..ba9c929ec 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -41,6 +41,11 @@ class="flex flex-col h-full gap-8 sm:flex-row"> shortConfirmationLabel="Confirmation text" />
@endif +
+ +
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 000000000..01639d497 --- /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 1388e2980..4a54030bd 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -4,22 +4,26 @@ 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 000000000..86186cca8 --- /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 000000000..4199eca5f --- /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 000000000..0d48225eb --- /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 000000000..96b01d8d9 --- /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 000000000..e547cde98 --- /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 000000000..48fb50c37 --- /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 000000000..9d2c7167b --- /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 000000000..6be325a0a --- /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 02/11] 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 d9e6729c8..6194b2a1e 100644 --- a/tests/Feature/Livewire/ConfigurationCheckerTest.php +++ b/tests/Feature/Livewire/ConfigurationCheckerTest.php @@ -110,6 +110,9 @@ function markConfigurationCheckerApplicationDeployed(Application $application): }); 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 @@ function markConfigurationCheckerApplicationDeployed(Application $application): }); 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 20b7c0adc..261ef9434 100644 --- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php +++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php @@ -74,7 +74,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat ->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 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat $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 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat 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 03/11] 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 93a88868a..04cb4effe 100644 --- a/app/Auth/Oidc/OidcConfig.php +++ b/app/Auth/Oidc/OidcConfig.php @@ -28,7 +28,7 @@ public static function fromOauthSetting(OauthSetting $setting): self 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 0d35ff45c..3a39f33cf 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 @@ public function discover(string $issuerUrl): OidcDiscoveryDocument */ 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 @@ public function jwks(string $jwksUri): array 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 98dc6d4c0..ee13cf712 100644 --- a/app/Auth/Oidc/OidcTokenValidator.php +++ b/app/Auth/Oidc/OidcTokenValidator.php @@ -41,10 +41,22 @@ public function validate( $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 @@ private function assertAudience(array $claims, string $clientId): void 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 65a476d45..34bb2a112 100644 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -18,6 +18,8 @@ class OidcProvider extends AbstractProvider implements ProviderInterface { + private const int OIDC_FLOW_TTL_MINUTES = 10; + /** * @var array */ @@ -65,12 +67,12 @@ protected function getAuthUrl($state): string { $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 @@ protected function getUserByToken($token): array 'Accept' => 'application/json', 'Authorization' => 'Bearer '.$token, ], + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::TIMEOUT => 10, ]); $decoded = json_decode((string) $response->getBody(), true); @@ -135,12 +139,17 @@ public function user() $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 @@ public function getAccessTokenResponse($code) { $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 @@ public function getAccessTokenResponse($code) $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 @@ protected function resolveName(array $user): ?string 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 845c25a54..c29bcbb1b 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -163,7 +163,7 @@ public function instantSaveDiscordEnabled() } } - public function toggleDiscordEnabled() + public function toggleDiscordEnabled(): void { try { $this->resetErrorBag(); @@ -183,7 +183,7 @@ public function toggleDiscordEnabled() } 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 ebb49e0a9..7d11cc725 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -246,7 +246,7 @@ public function instantSaveTelegramEnabled() } } - public function toggleTelegramEnabled() + public function toggleTelegramEnabled(): void { try { $this->resetErrorBag(); @@ -268,7 +268,7 @@ public function toggleTelegramEnabled() } 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 18d07ea90..58ca7b71b 100644 --- a/app/Livewire/Server/LogDrains.php +++ b/app/Livewire/Server/LogDrains.php @@ -177,8 +177,12 @@ public function instantSave() } } - 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 @@ public function toggleLogDrain(string $type) $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 9e0093605..a7b1c605f 100644 --- a/app/Livewire/SettingsEmail.php +++ b/app/Livewire/SettingsEmail.php @@ -187,6 +187,10 @@ public function submitSmtp() 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 @@ public function submitResend() 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 24eddfaf9..a3025f593 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 @@ public function mount(?string $provider = null) 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 @@ public function saveRegistrationPolicy(): void 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 feb36211c..25c9c465b 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 a36d2febd..1d1bd9838 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 eb9cb1b6b..2b07bc713 100644 --- a/resources/views/livewire/settings-email.blade.php +++ b/resources/views/livewire/settings-email.blade.php @@ -36,14 +36,14 @@ class="flex flex-col h-full gap-8 sm:flex-row">

SMTP Server

@if ($smtpEnabled) - + Save - + Disable SMTP Server @else - + Enable SMTP Server @endif @@ -70,14 +70,14 @@ class="flex flex-col h-full gap-8 sm:flex-row">

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 82ad9d581..8308e88d0 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -31,7 +31,8 @@ class="menu-item-label">{{ $oauth_setting['label'] }} 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 @@ class="menu-item-label">{{ $oauth_setting['label'] }}

{{ $oauth_setting['label'] }}

@if ($oauth_setting['enabled']) - + Save - + Disable {{ $oauth_setting['label'] }} @else - + Enable {{ $oauth_setting['label'] }} @endif @@ -64,19 +67,24 @@ class="menu-item-label">{{ $oauth_setting['label'] }}
- - - @if ($oauth_setting['provider'] == 'azure') - @endif @if ($oauth_setting['provider'] == 'google') - @endif @@ -85,40 +93,44 @@ class="menu-item-label">{{ $oauth_setting['label'] }} $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 ba9c929ec..25a961fb5 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -42,7 +42,8 @@ class="flex flex-col h-full gap-8 sm:flex-row">
@endif
-
diff --git a/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php b/tests/Feature/LogDrain/LogDrainToggleRollbackTest.php new file mode 100644 index 000000000..994c96398 --- /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 4a54030bd..27660a6bb 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -80,4 +80,6 @@ })->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 4199eca5f..b37694bbc 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -113,6 +113,7 @@ function fakeOidcProvider(array $claims = []): void $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 000000000..7e6b6ff23 --- /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 e547cde98..0d25ce0f3 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -215,6 +215,25 @@ function actingAsInstanceAdmin(): User 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 9d2c7167b..79173a415 100644 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -55,3 +55,17 @@ 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 000000000..9726f08b0 --- /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 6be325a0a..e2546efe1 100644 --- a/tests/Unit/OidcTokenValidatorTest.php +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -111,6 +111,9 @@ function oidc_discovery(): OidcDiscoveryDocument '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 04/11] 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 000000000..f57ddae55 --- /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 ee13cf712..a8563611d 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 @@ public function validate( ?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 @@ public function validate( 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 @@ private function assertSubject(array $claims): void } } - /** - * @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 @@ private function assertAudience(array $claims, string $clientId): void } } - /** - * @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 3faef77b2..000000000 --- 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 34bb2a112..b9fc16f9c 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 @@ public function user() 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 @@ public function user() 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 25c9c465b..b0c47a395 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -66,7 +66,7 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, 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 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, } $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 9415aa624..923bc841b 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 7d958a9cc..9866c8474 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 b37694bbc..6851ba97a 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -93,6 +93,21 @@ function fakeOidcProvider(array $claims = []): void ]); }); +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 79173a415..458dfeede 100644 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -38,6 +38,25 @@ 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 e2546efe1..3ca021de5 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 05/11] 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 19c4445b2..13fe6b678 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 @@ /** * 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 25a961fb5..9a8c99854 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -42,7 +42,7 @@ class="flex flex-col h-full gap-8 sm:flex-row">
@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 06/11] 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 b0c47a395..f1c3b4d82 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -66,7 +66,9 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, 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 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, if ($identity) { $identity->update([ 'email' => $email, - 'raw_claims' => $oauthUser->user, + 'raw_claims' => $rawClaims, 'last_login_at' => now(), ]); @@ -107,7 +109,7 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, '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 6851ba97a..6ea861ec1 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -131,6 +131,43 @@ function fakeOidcProvider(array $claims = []): void 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 3ca021de5..9b1d9a24c 100644 --- a/tests/Unit/OidcTokenValidatorTest.php +++ b/tests/Unit/OidcTokenValidatorTest.php @@ -108,6 +108,7 @@ function oidc_discovery(): OidcDiscoveryDocument })->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 07/11] 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 b9fc16f9c..383b0cc91 100644 --- a/app/Auth/Oidc/Socialite/OidcProvider.php +++ b/app/Auth/Oidc/Socialite/OidcProvider.php @@ -209,9 +209,11 @@ public function getAccessTokenResponse($code) $fields = $this->getTokenFields($code); if ($this->getConfig()->usePkce) { $verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state'))); - if ($verifier !== null) { - $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 8b4c902d5..d038d9a95 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -5,6 +5,7 @@ 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 @@ private function validationRules(?string $provider = null): array 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 @@ public function mount(?string $provider = null) 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 9726f08b0..b92ff58ff 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 @@ function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl $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 08/11] 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 e3223ea98..0847afc9a 100644 --- a/app/Auth/Oidc/OidcDiscoveryService.php +++ b/app/Auth/Oidc/OidcDiscoveryService.php @@ -17,7 +17,7 @@ public function discover(string $issuerUrl): OidcDiscoveryDocument $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 @@ public function discover(string $issuerUrl): OidcDiscoveryDocument 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 458dfeede..18c358fd1 100644 --- a/tests/Unit/OidcDiscoveryServiceTest.php +++ b/tests/Unit/OidcDiscoveryServiceTest.php @@ -38,6 +38,35 @@ 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 09/11] 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 d038d9a95..a3ee2c987 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -42,6 +42,7 @@ private function validationRules(?string $provider = null): array $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 @@ private function fillOauthSetting(OauthSetting $oauth, array $data): void '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 @@ private function oauthSettingToArray(OauthSetting $setting): array '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 25e38b4d1..bc6fc06c1 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 f1c3b4d82..f656fad08 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -44,7 +44,7 @@ private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, 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 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, 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 @@ private function canCreateUser(OauthSetting $oauthSetting): bool 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 @@ private function createUser(string $name, string $email): User 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 000000000..b0f5aad18 --- /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 8308e88d0..1ff75512e 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -136,6 +136,14 @@ class="menu-item-label">{{ $oauth_setting['label'] }}
@endif +
+
+ +
+
@endif diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php index 6ea861ec1..21b335016 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -93,6 +93,40 @@ function fakeOidcProvider(array $claims = []): void ]); }); +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 0d25ce0f3..e55cdac4f 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 @@ function actingAsInstanceAdmin(): User $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 @@ function actingAsInstanceAdmin(): User ->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 @@ function actingAsInstanceAdmin(): User ->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 8400e26a3fa68e29747178308c8fcd591f739ffe Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:22:49 +0200 Subject: [PATCH 10/11] feat(auth): consolidate OAuth settings into standard authentication page Display all OAuth providers on one settings page, prioritize OpenID Connect, and add its provider icon. --- app/Livewire/SettingsOauth.php | 10 +- public/svgs/oidc.svg | 5 + .../views/livewire/settings-oauth.blade.php | 255 ++++++++---------- tests/Feature/SettingsOauthTest.php | 40 +-- 4 files changed, 144 insertions(+), 166 deletions(-) create mode 100644 public/svgs/oidc.svg diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index a3ee2c987..3b24d0cd2 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -66,11 +66,13 @@ public function mount(?string $provider = null): ?RedirectResponse $this->settings = instanceSettings(); $this->selectedProvider = $provider; $this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled; - $this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) { - $carry[$setting->provider] = $this->oauthSettingToArray($setting); + $this->oauth_settings_map = OauthSetting::all() + ->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider) + ->reduce(function ($carry, $setting) { + $carry[$setting->provider] = $this->oauthSettingToArray($setting); - return $carry; - }, []); + return $carry; + }, []); if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) { abort(404); diff --git a/public/svgs/oidc.svg b/public/svgs/oidc.svg new file mode 100644 index 000000000..9c542584e --- /dev/null +++ b/public/svgs/oidc.svg @@ -0,0 +1,5 @@ + + OpenID Connect + + + diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 1ff75512e..4a394a0b9 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -1,153 +1,122 @@
- Settings | Coolify + Authentication | Coolify - -
- - - - @if ($selectedProvider === null) -
-
-

Authentication

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

Registration

- + + +
+ + {{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }} +
-
+ + +
+ + + + + @if ($provider === 'azure') + + @endif + + @if ($provider === 'google') + + @endif + + @if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true)) + + @endif + + @if ($provider === 'oidc') + + + + + @endif +
+ +
+ @if ($provider === 'oidc') -
+ id="oauth_settings_map.{{ $provider }}.allow_registration" + label="Allow OIDC user creation" + helper="Allow a successful OIDC login to create a user when password registration is disabled." /> + + + @endif +
-
- @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 + + @endforeach -
+
diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index e55cdac4f..6849b3d79 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -36,31 +36,33 @@ function actingAsInstanceAdmin(): User OauthSetting::create(['provider' => 'bitbucket']); }); -it('shows oauth general settings with provider subnavigation', function () { +it('uses the standard settings design and keeps every oauth provider on one page', function () { actingAsInstanceAdmin(); $this->withoutMiddleware(DecideWhatToDoWithUser::class) ->get(route('settings.oauth')) ->assertSuccessful() - ->assertSee('General') + ->assertSee('Authentication') + ->assertSee('Registration') ->assertSee('Authentik') ->assertSee('Bitbucket') - ->assertSee(route('settings.oauth.provider', 'authentik'), false) - ->assertSee(route('settings.oauth.provider', 'bitbucket'), false) + ->assertSee('OpenID Connect') ->assertSee('Disable password registration when OAuth is enabled') - ->assertDontSee('Client Secret'); + ->assertSee('Client secret') + ->assertSee('application-settings-form', false) + ->assertDontSee(route('settings.oauth.provider', 'authentik'), false); }); -it('shows the registration helper next to the section title in a wider row', function () { +it('lists openid connect before the other oauth providers', 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); + $providers = array_keys(Livewire::test(SettingsOauth::class)->get('oauth_settings_map')); + + expect($providers[0])->toBe('oidc'); +}); + +it('has an icon for openid connect', function () { + expect(public_path('svgs/oidc.svg'))->toBeFile(); }); it('auto saves registration policy without a general save button', function () { @@ -81,24 +83,24 @@ function actingAsInstanceAdmin(): User expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue(); }); -it('shows a provider settings page with a naked okta issuer url example', function () { +it('shows oidc fields with a naked okta issuer url example', function () { actingAsInstanceAdmin(); $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth.provider', 'oidc')) + ->get(route('settings.oauth')) ->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 () { +it('shows provider enable controls as settings section actions', function () { actingAsInstanceAdmin(); $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth.provider', 'authentik')) + ->get(route('settings.oauth')) ->assertSuccessful() - ->assertSee('Enable Authentik') + ->assertSee('Enable') ->assertDontSee('label="Enabled"', false) ->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false); }); @@ -107,7 +109,7 @@ function actingAsInstanceAdmin(): User actingAsInstanceAdmin(); $this->withoutMiddleware(DecideWhatToDoWithUser::class) - ->get(route('settings.oauth.provider', 'oidc')) + ->get(route('settings.oauth')) ->assertSuccessful() ->assertSee('Allow OIDC user creation') ->assertSee('Require verified email') From ce2ea0544ef29863adaa8a5298a015614d63380c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:33:21 +0200 Subject: [PATCH 11/11] feat(auth): improve OAuth buttons and OIDC settings layout Add provider icons and full-width centered login buttons, organize OIDC fields, and remove hardcoded user IDs from the seeder. --- database/seeders/UserSeeder.php | 2 - resources/views/auth/login.blade.php | 6 +- .../views/livewire/settings-oauth.blade.php | 55 +++++++++++-------- tests/Feature/LoginPageBrandingTest.php | 22 ++++++++ tests/Feature/SettingsOauthTest.php | 21 +++++++ tests/Feature/UserSeederTest.php | 16 ++++++ 6 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 tests/Feature/UserSeederTest.php diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index 2ac615cc0..19d3aa42e 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -15,12 +15,10 @@ public function run(): void 'email' => 'test@example.com', ]); User::factory()->create([ - 'id' => 1, 'name' => 'Normal User (but in root team)', 'email' => 'test2@example.com', ]); User::factory()->create([ - 'id' => 2, 'name' => 'Normal User (not in root team)', 'email' => 'test3@example.com', ]); diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index b0b0dd4a3..12eb57867 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -80,10 +80,14 @@ class="auth-tooltip max-w-xs whitespace-normal"> @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
-
+
@foreach ($enabled_oauth_providers as $provider_setting) + @if ($provider_setting->provider !== 'oidc') + + @endif {{ $provider_setting->loginLabel() }} @endforeach diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 4a394a0b9..822c035b3 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -55,14 +55,39 @@
- - - + @if ($provider === 'oidc') + + + + + + +
+ +
+ @else + + + + @endif @if ($provider === 'azure') @endif - @if ($provider === 'oidc') - - - - - @endif
diff --git a/tests/Feature/LoginPageBrandingTest.php b/tests/Feature/LoginPageBrandingTest.php index 5d60290ab..ffe7da67c 100644 --- a/tests/Feature/LoginPageBrandingTest.php +++ b/tests/Feature/LoginPageBrandingTest.php @@ -37,6 +37,28 @@ ->not->toMatch('/\.auth-shell\s*\{[^}]*color-mix\(in oklab, var\(--color-accent\) 9%, transparent\)/s'); }); +test('external login providers are centered and full width', function () { + $login = file_get_contents(resource_path('views/auth/login.blade.php')); + + expect($login) + ->toContain('class="flex flex-col gap-2"') + ->toContain('class="w-full justify-center"') + ->not->toContain('sm:w-[calc(50%-0.25rem)]'); +}); + +test('external login providers display their icons except oidc', function () { + $login = file_get_contents(resource_path('views/auth/login.blade.php')); + + expect($login) + ->toContain("@if (\$provider_setting->provider !== 'oidc')") + ->toContain("asset('svgs/'.\$provider_setting->provider.'.svg')") + ->toContain('class="size-5 shrink-0 dark:invert"'); + + foreach (['authentik', 'azure', 'bitbucket', 'clerk', 'discord', 'github', 'gitlab', 'google', 'infomaniak', 'zitadel'] as $provider) { + expect(public_path("svgs/{$provider}.svg"))->toBeFile(); + } +}); + test('error pages use the Coollabs purple background glow', function () { $styles = file_get_contents(resource_path('css/app.css')); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index 6849b3d79..95ea47e94 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -94,6 +94,27 @@ function actingAsInstanceAdmin(): User ->assertDontSee('/oauth2/default', false); }); +it('groups oidc fields in the expected desktop order', function () { + $view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php')); + $fields = [ + 'redirect_uri', + 'base_url', + 'client_id', + 'client_secret', + 'scopes', + 'clock_skew_seconds', + 'custom_label', + ]; + $positions = array_map( + fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""), + $fields, + ); + + expect($positions)->not->toContain(false) + ->and($positions)->toBe(collect($positions)->sort()->values()->all()) + ->and($view)->toContain('
'); +}); + it('shows provider enable controls as settings section actions', function () { actingAsInstanceAdmin(); diff --git a/tests/Feature/UserSeederTest.php b/tests/Feature/UserSeederTest.php new file mode 100644 index 000000000..d8ccf8651 --- /dev/null +++ b/tests/Feature/UserSeederTest.php @@ -0,0 +1,16 @@ +seed(UserSeeder::class); + + $user = User::factory()->create(); + + expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3]) + ->and($user->id)->toBe(3); +});