Files
coolify/app/Livewire/SettingsOauth.php
T
Andras Bacsai 3dc89cfe05 fix(oauth): require redirect URI to point at the provider callback route
Validate that the OAuth redirect URI path matches the auth.callback
route for the provider. Any other path falls through to the catch-all
route and silently sends the user back to the login page, so reject it
with a clear validation error instead.
2026-11-03 21:22:30 +01:00

357 lines
13 KiB
PHP

<?php
namespace App\Livewire;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
use Livewire\Features\SupportRedirects\Redirector;
class SettingsOauth extends Component
{
use AuthorizesRequests;
public InstanceSettings $settings;
public $oauth_settings_map;
public ?string $selectedProvider = null;
public bool $disable_registration_when_oauth_enabled = false;
protected function rules(): array
{
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', $this->callbackPathRule($setting->provider)];
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
return $carry;
}, []);
if ($provider === null) {
$rules['disable_registration_when_oauth_enabled'] = 'boolean';
}
return $rules;
}
/**
* The provider sends the browser back to the redirect URI. Only the callback route can
* finish the login; any other path falls through to the catch-all route, which sends
* the user to the login page without an error.
*/
private function callbackPathRule(string $provider): \Closure
{
return function (string $attribute, mixed $value, \Closure $fail) use ($provider): void {
$expectedPath = route('auth.callback', $provider, false);
$path = rtrim((string) parse_url((string) $value, PHP_URL_PATH), '/');
if ($path !== $expectedPath) {
$fail("The redirect URI must end with {$expectedPath}.");
}
};
}
public function mount(?string $provider = null): ?Redirector
{
if (! isInstanceAdmin()) {
return redirect()->route('dashboard');
}
$this->settings = instanceSettings();
$this->selectedProvider = $provider;
$this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
$this->oauth_settings_map = OauthSetting::all()
->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
return $carry;
}, []);
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
abort(404);
}
return null;
}
private function updateOauthSettings(?string $provider = null): void
{
$this->validate($this->validationRules($provider));
if ($provider) {
$oauthData = $this->oauth_settings_map[$provider];
$oauth = OauthSetting::find($oauthData['id']);
if (! $oauth) {
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
$this->fillOauthSetting($oauth, $oauthData);
$this->ensureProviderCanBeEnabled($oauth);
$oauth->save();
$this->auditOauthSettings($oauth, 'updated');
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
$this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!');
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->auditOauthSettings($oauth, 'updated');
$this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth);
}
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
auditLog('ui.instance.authentication.updated', [
'team_id' => null,
'resource' => 'instance',
'changed_fields' => ['disable_registration_when_oauth_enabled'],
]);
if (! empty($errors)) {
$this->dispatch('error', implode('<br/>', $errors));
}
}
private function fillOauthSetting(OauthSetting $oauth, array $data): void
{
$oauth->fill([
'enabled' => (bool) ($data['enabled'] ?? false),
'client_id' => $data['client_id'] ?? null,
'client_secret' => $data['client_secret'] ?? null,
'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null),
'tenant' => $data['tenant'] ?? null,
'base_url' => $this->nullableString($data['base_url'] ?? null),
'custom_label' => $data['custom_label'] ?? null,
'scopes' => $data['scopes'] ?? null,
'allow_registration' => (bool) ($data['allow_registration'] ?? false),
'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false),
'require_email_verified' => (bool) ($data['require_email_verified'] ?? true),
'use_pkce' => (bool) ($data['use_pkce'] ?? true),
'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60),
]);
}
private function nullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function ensureProviderCanBeEnabled(OauthSetting $oauth): void
{
if (! $oauth->enabled) {
return;
}
if (! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
$oauth->update(['enabled' => false]);
throw new \Exception("OIDC scopes must include 'openid'.");
}
}
private function oauthSettingToArray(OauthSetting $setting): array
{
return [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
'custom_label' => $setting->custom_label,
'scopes' => $setting->scopes ?: 'openid email profile',
'allow_registration' => $setting->allow_registration,
'auto_join_root_team' => $setting->auto_join_root_team,
'require_email_verified' => $setting->require_email_verified ?? true,
'use_pkce' => $setting->use_pkce ?? true,
'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,
'label' => $this->providerLabel($setting->provider),
];
}
public function providerLabel(string $provider): string
{
return match ($provider) {
'oidc' => 'OpenID Connect',
'gitlab' => 'GitLab',
default => str($provider)->headline()->toString(),
};
}
public function instantSave(string $provider)
{
try {
$this->authorize('update', instanceSettings());
$this->updateOauthSettings($provider);
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function toggleProvider(string $provider)
{
try {
$this->authorize('update', instanceSettings());
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.'.<br/>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->authorize('update', instanceSettings());
$this->validate([
'disable_registration_when_oauth_enabled' => 'boolean',
]);
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
auditLog('ui.instance.authentication.updated', [
'team_id' => null,
'resource' => 'instance',
'changed_fields' => ['disable_registration_when_oauth_enabled'],
]);
$this->dispatch('success', 'Authentication settings updated successfully!');
}
public function submit(): void
{
try {
$this->authorize('update', instanceSettings());
$this->updateOauthSettings($this->selectedProvider);
if ($this->selectedProvider === null) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->selectedProvider !== null) {
$oauth = OauthSetting::where('provider', $this->selectedProvider)->first();
if ($oauth) {
$this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth);
}
}
handleError($e, $this);
}
}
private function auditOauthSettings(OauthSetting $oauth, string $action): void
{
auditLog("ui.oauth_setting.{$action}", [
'team_id' => null,
'resource' => 'oauth_setting',
'oauth_setting_name' => $oauth->provider,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'changed_fields' => array_values(array_diff(array_keys($oauth->getChanges()), ['client_secret', 'updated_at'])),
]);
}
}