Reapply "Merge origin/next into main"

This reverts commit 15359833d3.
This commit is contained in:
Andras Bacsai 2026-08-19 12:39:55 +02:00
parent 15359833d3
commit 81227670e6
96 changed files with 4859 additions and 320 deletions

View file

@ -32,7 +32,7 @@ public function __construct(private readonly Request $request) {}
public function create(array $input): User
{
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
if (! $settings->isPasswordRegistrationAllowed()) {
abort(403);
}

View file

@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class CheckUpdates
@ -106,6 +107,15 @@ public function handle(Server $server)
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
case 'apk':
instant_remote_process(['apk update -q'], $server);
$output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
$out = $this->parseApkOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
default:
return [
@ -266,11 +276,39 @@ private function parsePacmanOutput(string $output): array
// Include unparsed lines in the result for debugging if any exist
if (! empty($unparsedLines)) {
$result['unparsed_lines'] = $unparsedLines;
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
Log::debug('Pacman output contained unparsed lines', [
'unparsed_lines' => $unparsedLines,
]);
}
return $result;
}
private function parseApkOutput(string $output): array
{
$updates = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
// Skip empty lines
if (empty($line)) {
continue;
}
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
$updates[] = [
'package' => $matches[1],
'new_version' => $matches[2],
'architecture' => $matches[3],
'current_version' => $matches[4],
];
}
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
}
}

View file

@ -79,6 +79,8 @@ public function handle(Server $server)
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} elseif ($supported_os_type->contains('arch')) {
$command = $command->merge([$this->getArchDockerInstallCommand()]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge([$this->getAlpineDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@ -93,9 +95,8 @@ public function handle(Server $server)
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
"echo 'Restarting Docker Engine...'",
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
]);
$command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
if ($server->isSwarm()) {
$command = $command->merge([
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
@ -154,6 +155,28 @@ private function getArchDockerInstallCommand(): string
'systemctl start docker.service';
}
private function getAlpineDockerInstallCommand(): string
{
return 'apk update && '.
'apk add docker docker-cli-buildx docker-cli-compose && '.
'mkdir -p /etc/docker';
}
private function getDockerServiceCommands(bool $usesOpenRc): array
{
if ($usesOpenRc) {
return [
'rc-update add docker default',
'rc-service docker restart',
];
}
return [
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
];
}
private function getGenericDockerInstallCommand(): string
{
return 'curl -fsSL https://get.docker.com | sh';

View file

@ -53,6 +53,8 @@ public function handle(Server $server)
"echo 'Installing Prerequisites for Arch Linux...'",
'pacman -Syu --noconfirm --needed curl wget git jq',
]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge($this->getAlpinePrerequisiteCommands());
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
@ -61,4 +63,18 @@ public function handle(Server $server)
return remote_process($command, $server);
}
private function getAlpinePrerequisiteCommands(): array
{
return [
"echo 'Installing Prerequisites for Alpine Linux...'",
"sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
'apk update',
'command -v bash >/dev/null || apk add bash',
'command -v curl >/dev/null || apk add curl',
'command -v wget >/dev/null || apk add wget',
'command -v git >/dev/null || apk add git',
'command -v jq >/dev/null || apk add jq',
];
}
}

View file

@ -58,6 +58,10 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s
$commandAll = 'pacman -Syu --noconfirm';
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
case 'apk':
$commandAll = 'apk update && apk upgrade';
$commandInstall = 'apk upgrade '.$sanitizedPackage;
break;
default:
return [
'error' => 'OS not supported',

View file

@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcDiscoveryException extends OidcException {}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Auth\Oidc\Exceptions;
use RuntimeException;
class OidcException extends RuntimeException {}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcJwksException extends OidcException {}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcSigningKeyNotFoundException extends OidcTokenException {}

View file

@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcTokenException extends OidcException {}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Auth\Oidc;
use App\Models\OauthSetting;
final readonly class OidcConfig
{
/**
* @param array<int, string> $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,
);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
final readonly class OidcDiscoveryDocument
{
/**
* @param array<int, string> $supportedScopes
* @param array<int, string> $supportedClaims
* @param array<int, string> $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<string, mixed> $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<int, string>
*/
private static function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_map('strval', $value));
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
use App\Auth\Oidc\Exceptions\OidcJwksException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Throwable;
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);
return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument {
$url = $issuerUrl.'/.well-known/openid-configuration';
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
} catch (Throwable $e) {
throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
}
if ($response->failed()) {
throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}");
}
$json = $response->json();
if (! is_array($json) || $json === []) {
throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.');
}
$discovery = OidcDiscoveryDocument::fromArray($json);
if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
}
return $discovery;
});
}
/**
* Fetch the JWKS for the given URI.
*
* When $forceRefresh is true the cached document is bypassed so freshly
* rotated signing keys become visible immediately. A short cooldown still
* prevents a flood of upstream requests if many logins miss the same kid.
*
* @return array<string, mixed>
*/
public function jwks(string $jwksUri, bool $forceRefresh = false): array
{
$this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.'));
$cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
if ($forceRefresh) {
$cooldownKey = $cacheKey.':refresh';
if (Cache::add($cooldownKey, true, 60)) {
Cache::forget($cacheKey);
}
}
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
} catch (Throwable $e) {
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
}
if ($response->failed()) {
throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
}
$json = $response->json();
if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
}
return $json;
});
}
private function assertHttpsUrl(string $url, Throwable $exception): void
{
$parts = parse_url($url);
if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') {
throw $exception;
}
}
}

View file

@ -0,0 +1,199 @@
<?php
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<string, mixed> $jwks
* @return array<string, mixed>
*/
public function validate(
string $idToken,
OidcDiscoveryDocument $discovery,
array $jwks,
string $clientId,
?string $expectedNonce = null,
int $clockSkewSeconds = 60,
): array {
$kid = $this->extractKid($idToken);
try {
$keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM);
} catch (Throwable $e) {
throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e);
}
// Surface an unknown signing key distinctly so the caller can refresh
// the JWKS once (key rotation) before giving up.
if (! array_key_exists($kid, $keys)) {
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
}
$previousLeeway = JWT::$leeway;
JWT::$leeway = $clockSkewSeconds;
try {
// Validates signature, header alg against the key alg (RS256),
// exp, nbf and iat. Throws on any failure.
$claims = (array) JWT::decode($idToken, $keys);
} catch (OidcTokenException $e) {
throw $e;
} catch (Throwable $e) {
throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e);
} finally {
JWT::$leeway = $previousLeeway;
}
$this->assertExpiry($claims);
$this->assertIssuer($claims, $discovery->issuer);
$this->assertAudience($claims, $clientId);
$this->assertNonce($claims, $expectedNonce);
$this->assertSubject($claims);
return $claims;
}
/**
* Drop JWKS entries explicitly marked for anything other than signing
* (e.g. "use":"enc") so they can never verify an id_token signature.
* firebase/php-jwt does not honour the "use" parameter on its own.
*
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
private function signingKeysOnly(array $jwks): array
{
$keys = array_values(array_filter(
$jwks['keys'] ?? [],
fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'),
));
return ['keys' => $keys];
}
/**
* Decode just the JWT header to read the kid before signature
* verification, so an unknown key can be reported as a rotation miss.
*/
private function extractKid(string $idToken): string
{
$segments = explode('.', $idToken);
if (count($segments) !== 3) {
throw new OidcTokenException('Malformed id_token.');
}
$header = json_decode($this->base64UrlDecode($segments[0]), true);
if (! is_array($header)) {
throw new OidcTokenException('id_token header contains invalid JSON.');
}
if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) {
throw new OidcTokenException('id_token uses a disallowed algorithm.');
}
$kid = $header['kid'] ?? null;
if (! is_string($kid) || $kid === '') {
throw new OidcTokenException('id_token header is missing kid.');
}
return $kid;
}
private function base64UrlDecode(string $value): string
{
$remainder = strlen($value) % 4;
if ($remainder !== 0) {
$value .= str_repeat('=', 4 - $remainder);
}
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
if ($decoded === false) {
throw new OidcTokenException('Invalid base64url value in id_token header.');
}
return $decoded;
}
/**
* @param array<string, mixed> $claims
*/
private function assertExpiry(array $claims): void
{
// Firebase enforces the exp window when present; OIDC requires it to exist.
if (! is_numeric($claims['exp'] ?? null)) {
throw new OidcTokenException('id_token is missing the exp claim.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertSubject(array $claims): void
{
$subject = $claims['sub'] ?? null;
if (! is_string($subject) || $subject === '') {
throw new OidcTokenException('id_token subject is missing or invalid.');
}
}
/**
* @param array<string, mixed> $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<string, mixed> $claims
*/
private function assertAudience(array $claims, string $clientId): void
{
$audience = $claims['aud'] ?? null;
if (is_string($audience)) {
$audience = [$audience];
}
if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
throw new OidcTokenException('id_token audience does not include configured client id.');
}
if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) {
throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.');
}
if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
throw new OidcTokenException('id_token azp does not match configured client id.');
}
}
/**
* @param array<string, mixed> $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.');
}
}
}

View file

@ -0,0 +1,32 @@
<?php
namespace App\Auth\Oidc;
use Laravel\Socialite\Two\User as SocialiteUser;
class OidcUser extends SocialiteUser
{
public ?string $issuer = null;
public ?string $subject = null;
public bool $emailVerified = false;
/**
* @var array<string, mixed>
*/
public array $idTokenClaims = [];
/**
* @param array<string, mixed> $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;
}
}

View file

@ -0,0 +1,299 @@
<?php
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;
use App\Auth\Oidc\OidcTokenValidator;
use App\Auth\Oidc\OidcUser;
use GuzzleHttp\RequestOptions;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\InvalidStateException;
use Laravel\Socialite\Two\ProviderInterface;
class OidcProvider extends AbstractProvider implements ProviderInterface
{
private const int OIDC_FLOW_TTL_MINUTES = 10;
/**
* @var array<int, string>
*/
protected $scopes = ['openid', 'email', 'profile'];
protected $scopeSeparator = ' ';
protected ?OidcConfig $oidcConfig = null;
protected ?OidcDiscoveryDocument $discovery = null;
public function __construct(
Request $request,
protected OidcDiscoveryService $discoveryService,
protected OidcTokenValidator $tokenValidator,
string $clientId,
string $clientSecret,
string $redirectUrl,
) {
parent::__construct($request, $clientId, $clientSecret, $redirectUrl);
}
public function setConfig(OidcConfig $config): self
{
$this->oidcConfig = $config;
$this->clientId = $config->clientId;
$this->clientSecret = $config->clientSecret;
$this->redirectUrl = $config->redirectUri;
$this->scopes = $config->scopes;
$this->discovery = null;
return $this;
}
public function getConfig(): OidcConfig
{
if ($this->oidcConfig === null) {
throw new OidcException('OIDC provider config is not set.');
}
return $this->oidcConfig;
}
protected function getAuthUrl($state): string
{
$config = $this->getConfig();
$nonce = Str::random(40);
$this->putOidcFlowValue($this->nonceSessionKey($state), $nonce);
$extra = ['nonce' => $nonce];
if ($config->usePkce) {
$verifier = $this->generateCodeVerifier();
$this->putOidcFlowValue($this->verifierSessionKey($state), $verifier);
$extra['code_challenge'] = $this->codeChallenge($verifier);
$extra['code_challenge_method'] = 'S256';
}
return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
.'&'.http_build_query($extra, '', '&', $this->encodingType);
}
protected function getTokenUrl(): string
{
return $this->resolveDiscovery()->tokenEndpoint;
}
/**
* @return array<string, mixed>
*/
protected function getUserByToken($token): array
{
$response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
RequestOptions::HEADERS => [
'Accept' => 'application/json',
'Authorization' => 'Bearer '.$token,
],
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 10,
]);
$decoded = json_decode((string) $response->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string, mixed> $user
*/
protected function mapUserToObject(array $user)
{
return (new OidcUser)->setRaw($user)->map([
'id' => $user['sub'] ?? null,
'nickname' => $user['preferred_username'] ?? null,
'name' => $this->resolveName($user),
'email' => $user['email'] ?? null,
'avatar' => $user['picture'] ?? null,
]);
}
public function user()
{
if ($this->user) {
return $this->user;
}
if ($this->hasInvalidState()) {
throw new InvalidStateException;
}
$tokenResponse = $this->getAccessTokenResponse($this->getCode());
$accessToken = Arr::get($tokenResponse, 'access_token');
$idToken = Arr::get($tokenResponse, 'id_token');
if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') {
throw new OidcException('OIDC token endpoint did not return required tokens.');
}
$discovery = $this->resolveDiscovery();
$config = $this->getConfig();
$expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state')));
if ($expectedNonce === null) {
throw new OidcException('OIDC login session expired. Please try again.');
}
$claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce);
$userinfo = $this->getUserByToken($accessToken);
// OIDC core §5.3.2: the userinfo sub MUST match the id_token sub.
// Reject the response rather than trust unsigned userinfo claims.
$userinfoSub = $userinfo['sub'] ?? null;
if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) {
throw new OidcException('OIDC userinfo subject does not match the id_token subject.');
}
$merged = array_merge($userinfo, $claims);
/** @var OidcUser $user */
$user = $this->mapUserToObject($merged);
$user->setIdTokenClaims($claims)
->setToken($accessToken)
->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
return $this->user = $user;
}
/**
* Validate the id_token, retrying once against a freshly fetched JWKS when
* the signing key is unknown. This keeps logins working immediately after
* the IdP rotates keys instead of failing until the JWKS cache expires.
*
* @return array<string, mixed>
*/
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<string, mixed>
*/
public function getAccessTokenResponse($code)
{
$fields = $this->getTokenFields($code);
if ($this->getConfig()->usePkce) {
$verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state')));
if ($verifier === null) {
throw new OidcException('OIDC login session expired. Please try again.');
}
$fields['code_verifier'] = $verifier;
}
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
RequestOptions::HEADERS => ['Accept' => 'application/json'],
RequestOptions::FORM_PARAMS => $fields,
RequestOptions::CONNECT_TIMEOUT => 5,
RequestOptions::TIMEOUT => 10,
]);
$decoded = json_decode((string) $response->getBody(), true);
return is_array($decoded) ? $decoded : [];
}
protected function resolveDiscovery(): OidcDiscoveryDocument
{
return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
}
protected function generateCodeVerifier(): string
{
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
}
protected function codeChallenge(string $verifier): string
{
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
}
/**
* @param array<string, mixed> $user
*/
protected function resolveName(array $user): ?string
{
if (is_string($user['name'] ?? null) && $user['name'] !== '') {
return $user['name'];
}
$name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? '')));
return $name === '' ? null : $name;
}
protected function putOidcFlowValue(string $key, string $value): void
{
$this->request->session()->put($key, [
'value' => $value,
'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp,
]);
}
protected function pullOidcFlowValue(string $key): ?string
{
$entry = $this->request->session()->pull($key);
if (! is_array($entry)) {
return null;
}
$value = $entry['value'] ?? null;
$expiresAt = $entry['expires_at'] ?? null;
if (! is_string($value) || $value === '' || ! is_int($expiresAt)) {
return null;
}
if ($expiresAt < now()->timestamp) {
return null;
}
return $value;
}
protected function nonceSessionKey(string $state): string
{
return "oidc.nonce.{$state}";
}
protected function verifierSessionKey(string $state): string
{
return "oidc.code_verifier.{$state}";
}
}

View file

@ -243,12 +243,18 @@ public static function generateSshCommand(Server $server, string $command, bool
$delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
$remoteShellCommand = self::remoteShellCommand();
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
}
private static function remoteShellCommand(): string
{
return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
}
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');

View file

@ -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;
}
}

View file

@ -166,6 +166,30 @@ public function instantSaveDiscordEnabled()
}
}
public function toggleDiscordEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->discordEnabled) {
$this->discordEnabled = false;
} else {
$this->validate([
'discordWebhookUrl' => 'required',
], [
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
]);
$this->discordEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
}
}
public function instantSave()
{
try {

View file

@ -2,7 +2,6 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@ -15,7 +14,7 @@
class Email extends Component
{
use AuthorizesRequests, TogglesNotificationEvents;
use AuthorizesRequests;
protected $listeners = ['refresh' => '$refresh'];
@ -252,32 +251,59 @@ 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()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
@ -309,17 +335,7 @@ public function submitResend()
try {
$this->resetErrorBag();
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
@ -336,6 +352,45 @@ 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',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {

View file

@ -159,6 +159,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 {

View file

@ -150,6 +150,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 {

View file

@ -252,6 +252,34 @@ public function instantSaveTelegramEnabled()
}
}
public function toggleTelegramEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->telegramEnabled) {
$this->telegramEnabled = false;
} else {
$this->validate([
'telegramToken' => 'required',
'telegramChatId' => 'required',
], [
'telegramToken.required' => 'Telegram Token is required.',
'telegramChatId.required' => 'Telegram Chat ID is required.',
]);
$this->telegramEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function saveModel()
{
$this->syncData(true);

View file

@ -144,6 +144,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 {

View file

@ -2,19 +2,15 @@
namespace App\Livewire\Profile;
use App\Services\AvatarStorageService;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Index extends Component
{
use WithFileUploads;
public int $userId;
public string $email;
@ -36,6 +32,10 @@ class Index extends Component
public bool $show_verification = false;
public bool $uses_sso = false;
public ?string $sso_provider_label = null;
public $avatar;
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
@ -75,8 +75,12 @@ public function mount()
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
$this->uses_sso = $oauthIdentity !== null;
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
// Check if there's a pending email change
if (Auth::user()->hasEmailChangeRequest()) {
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
$this->new_email = Auth::user()->pending_email;
$this->show_verification = true;
}
@ -101,6 +105,10 @@ public function submit()
public function requestEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// For self-hosted, check if email is enabled
if (! isCloud()) {
$settings = instanceSettings();
@ -159,6 +167,10 @@ public function requestEmailChange()
public function verifyEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
$this->validate([
'email_verification_code' => ['required', 'string', 'size:6'],
]);
@ -204,7 +216,6 @@ public function verifyEmailChange()
$this->show_verification = false;
$this->dispatch('success', 'Email address updated successfully.');
$this->dispatch('close-email-change-modal');
} else {
$this->dispatch('error', 'Failed to update email address.');
}
@ -216,6 +227,10 @@ 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.');
@ -269,6 +284,30 @@ public function cancelEmailChange()
$this->dispatch('success', 'Email change request cancelled.');
}
public function showEmailChangeForm()
{
if ($this->rejectSsoEmailChange()) {
return;
}
$this->show_email_change = true;
$this->new_email = '';
}
private function rejectSsoEmailChange(): bool
{
if (! Auth::user()->hasSsoIdentity()) {
return false;
}
$this->uses_sso = true;
$this->show_email_change = false;
$this->show_verification = false;
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
return true;
}
public function resetPassword()
{
try {
@ -299,6 +338,14 @@ 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');

View file

@ -0,0 +1,114 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\CloudflareTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class IntegrationTokenEditor extends Component
{
use AuthorizesRequests;
public IntegrationToken $integrationToken;
public string $name = '';
public string $newToken = '';
public array $capabilities = [];
public function mount(string $integration_token_uuid): void
{
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
->whereUuid($integration_token_uuid)
->firstOrFail();
$this->authorize('view', $this->integrationToken);
$this->name = $this->integrationToken->name;
$this->capabilities = $this->integrationToken->capabilities;
}
protected function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'newToken' => ['nullable', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:dns'],
];
}
protected function messages(): array
{
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
];
}
public function save(CloudflareTokenValidator $validator): void
{
$this->authorize('update', $this->integrationToken);
$validated = $this->validate();
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
try {
if ((filled($validated['newToken']) || $capabilitiesChanged)
&& ! $validator->validate($token, $validated['capabilities'])) {
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
return;
}
$updates = [
'name' => $validated['name'],
'capabilities' => $validated['capabilities'],
];
if (filled($validated['newToken'])) {
$updates['token'] = $validated['newToken'];
}
$this->integrationToken->update($updates);
$this->newToken = '';
auditLog('ui.integration_token.updated', [
'team_id' => currentTeam()->id,
'integration_token_uuid' => $this->integrationToken->uuid,
'integration_token_name' => $this->integrationToken->name,
'provider' => $this->integrationToken->provider,
'rotated' => array_key_exists('token', $updates),
]);
$this->dispatch(
'integration-token-updated',
uuid: $this->integrationToken->uuid,
name: $this->integrationToken->name,
capabilities: $this->integrationToken->capabilities,
);
$this->dispatch('success', 'Integration token updated successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function delete(string $password = ''): void
{
$this->authorize('delete', $this->integrationToken);
$this->integrationToken->delete();
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
$this->dispatch('close-modal');
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function render()
{
return view('livewire.security.integration-token-editor');
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use App\Services\CloudflareTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class IntegrationTokenForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = false;
public string $provider = 'cloudflare';
public string $name = '';
public string $token = '';
public array $capabilities = ['dns'];
public function mount(): void
{
$this->authorize('create', IntegrationToken::class);
}
protected function rules(): array
{
return [
'provider' => ['required', 'in:cloudflare'],
'name' => ['required', 'string', 'max:255'],
'token' => ['required', 'string'],
'capabilities' => ['required', 'array', 'min:1'],
'capabilities.*' => ['required', 'in:dns'],
];
}
protected function messages(): array
{
return [
'capabilities.required' => 'Select at least one capability.',
'capabilities.min' => 'Select at least one capability.',
];
}
public function addToken(CloudflareTokenValidator $validator): void
{
$validated = $this->validate();
try {
if (! $validator->validate($validated['token'], $validated['capabilities'])) {
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
return;
}
IntegrationToken::query()->create([
...$validated,
'team_id' => currentTeam()->id,
]);
$this->reset(['name', 'token']);
$this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
if ($this->modal_mode) {
$this->dispatch('close-modal');
}
$this->dispatch('success', 'Integration token added successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.integration-token-form');
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Livewire\Security;
use App\Models\IntegrationToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
class IntegrationTokens extends Component
{
use AuthorizesRequests;
public $tokens;
public function mount(): void
{
$this->authorize('viewAny', IntegrationToken::class);
$this->loadTokens();
}
#[On('integrationTokenAdded')]
public function loadTokens(): void
{
$this->tokens = IntegrationToken::ownedByCurrentTeam()->latest()->get();
}
public function deleteToken(int $tokenId, string $password = ''): void
{
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
$token->delete();
$this->loadTokens();
$this->dispatch('success', 'Integration token deleted successfully.');
}
public function render()
{
return view('livewire.security.integration-tokens');
}
}

View file

@ -177,6 +177,49 @@ public function instantSave()
}
}
public function toggleLogDrain(string $type): void
{
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
try {
$this->authorize('update', $this->server);
$this->resetErrorBag();
$enabledProperty = $this->enabledProperty($type);
if ($this->{$enabledProperty}) {
$this->{$enabledProperty} = false;
} else {
$this->validateLogDrainSettings($type);
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
$this->isLogDrainAxiomEnabled = $type === 'axiom';
$this->isLogDrainCustomEnabled = $type === 'custom';
}
$this->syncData(true);
if ($this->server->isLogDrainEnabled()) {
StartLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service started.');
} else {
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
$this->server->settings->save();
$this->syncData();
handleError($e, $this);
}
}
public function submit()
{
try {
@ -192,4 +235,33 @@ 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.'),
};
}
}

View file

@ -19,6 +19,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_registration_enabled;
#[Validate('boolean')]
public bool $disable_registration_when_oauth_enabled;
#[Validate('boolean')]
public bool $do_not_track;
@ -59,6 +62,7 @@ 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],
@ -84,6 +88,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;
@ -199,6 +204,7 @@ public function instantSave(?array $webhookAllowedInternalHosts = null)
try {
$this->authorize('update', $this->settings);
$this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->custom_dns_servers = $this->custom_dns_servers;

View file

@ -160,30 +160,59 @@ public function instantSaveResend(): void
$this->instantSave('Resend');
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->syncData(true);
$this->dispatch('success', 'SMTP settings updated.');
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->syncData(true);
$this->dispatch('success', 'Resend settings updated.');
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function submitSmtp()
{
try {
$this->authorize('update', $this->settings);
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
}
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_host = $this->smtpHost;
@ -210,17 +239,11 @@ public function submitResend()
{
try {
$this->authorize('update', $this->settings);
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
@ -237,6 +260,45 @@ 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',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {

View file

@ -2,53 +2,89 @@
namespace App\Livewire;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class SettingsOauth extends Component
{
use AuthorizesRequests;
public InstanceSettings $settings;
public $oauth_settings_map;
protected function rules()
public ?string $selectedProvider = null;
public bool $disable_registration_when_oauth_enabled = false;
protected function rules(): array
{
return OauthSetting::all()->reduce(function ($carry, $setting) {
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required';
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable';
return $this->validationRules();
}
private function validationRules(?string $provider = null): array
{
$rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) {
if ($provider !== null && $setting->provider !== $provider) {
return $carry;
}
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean';
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
return $carry;
}, []);
if ($provider === null) {
$rules['disable_registration_when_oauth_enabled'] = 'boolean';
}
return $rules;
}
public function mount()
public function mount(?string $provider = null): ?RedirectResponse
{
if (! isInstanceAdmin()) {
return redirect()->route('home');
}
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
$carry[$setting->provider] = [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
];
return $carry;
}, []);
$this->settings = instanceSettings();
$this->selectedProvider = $provider;
$this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
$this->oauth_settings_map = OauthSetting::all()
->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
return $carry;
}, []);
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
abort(404);
}
return null;
}
private function updateOauthSettings(?string $provider = null)
private function updateOauthSettings(?string $provider = null): void
{
$this->validate($this->validationRules($provider));
if ($provider) {
$oauthData = $this->oauth_settings_map[$provider];
$oauth = OauthSetting::find($oauthData['id']);
@ -57,78 +93,128 @@ 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 ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>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('<br/>', $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('<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)
@ -141,56 +227,88 @@ public function instantSave(string $provider)
}
}
public function toggleProvider(string $provider): mixed
public function toggleProvider(string $provider)
{
try {
$this->authorize('update', instanceSettings());
if (! array_key_exists($provider, $this->oauth_settings_map)) {
throw new \Exception('OAuth provider not found.');
abort(404);
}
$enabling = ! $this->oauth_settings_map[$provider]['enabled'];
if ($enabling) {
$this->validate($this->providerRules($provider));
if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
$this->validateProviderCanBeEnabled($provider);
}
$this->oauth_settings_map[$provider]['enabled'] = $enabling;
$this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
$this->updateOauthSettings($provider);
} catch (\Throwable $e) {
} catch (\Exception $e) {
$oauth = OauthSetting::where('provider', $provider)->first();
if ($oauth) {
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
}
return handleError($e, $this);
}
return null;
}
private function providerRules(string $provider): array
private function validateProviderCanBeEnabled(string $provider): void
{
$prefix = "oauth_settings_map.$provider";
$rules = [
"$prefix.client_id" => 'required',
"$prefix.client_secret" => 'required',
];
$this->validate($this->validationRules($provider));
if ($provider === 'azure') {
$rules["$prefix.tenant"] = 'required';
$oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
if (! $oauth) {
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
if (in_array($provider, ['authentik', 'clerk'], true)) {
$rules["$prefix.base_url"] = 'required';
$this->fillOauthSetting($oauth, [
...$this->oauth_settings_map[$provider],
'enabled' => true,
]);
if (! $oauth->couldBeEnabled()) {
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
return $rules;
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
throw new \Exception("OIDC scopes must include 'openid'.");
}
}
public function submit()
public function saveRegistrationPolicy(): void
{
$this->authorize('update', instanceSettings());
$this->validate([
'disable_registration_when_oauth_enabled' => 'boolean',
]);
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
$this->dispatch('success', 'Authentication settings updated successfully!');
}
public function submit(): void
{
try {
$this->authorize('update', instanceSettings());
$this->updateOauthSettings();
$this->dispatch('success', 'Instance settings updated successfully!');
} catch (\Throwable $e) {
return handleError($e, $this);
$this->updateOauthSettings($this->selectedProvider);
if ($this->selectedProvider === null) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->selectedProvider !== null) {
$oauth = OauthSetting::where('provider', $this->selectedProvider)->first();
if ($oauth) {
$this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth);
}
}
handleError($e, $this);
}
}
}

View file

@ -22,6 +22,7 @@ class InstanceSettings extends Model
'do_not_track',
'is_auto_update_enabled',
'is_registration_enabled',
'disable_registration_when_oauth_enabled',
'next_channel',
'smtp_enabled',
'smtp_from_address',
@ -88,6 +89,8 @@ class InstanceSettings extends Model
'allowed_ip_ranges' => 'array',
'is_auto_update_enabled' => 'boolean',
'is_registration_enabled' => 'boolean',
'disable_registration_when_oauth_enabled' => 'boolean',
'auto_update_frequency' => 'string',
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
@ -115,6 +118,19 @@ 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(

View file

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class IntegrationToken extends BaseModel
{
protected $fillable = [
'team_id',
'provider',
'name',
'token',
'capabilities',
];
protected $hidden = [
'token',
];
protected function casts(): array
{
return [
'token' => 'encrypted',
'capabilities' => 'array',
];
}
public function team(): BelongsTo
{
return $this->belongsTo(Team::class);
}
public static function ownedByCurrentTeam()
{
return self::query()->where('team_id', currentTeam()->id);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OauthIdentity extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'provider',
'issuer',
'provider_user_id',
'email',
'raw_claims',
'last_login_at',
];
protected function casts(): array
{
return [
'raw_claims' => 'array',
'last_login_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -11,7 +11,19 @@ class OauthSetting extends Model
{
use HasFactory;
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'allow_registration' => 'boolean',
'auto_join_root_team' => 'boolean',
'require_email_verified' => 'boolean',
'use_pkce' => 'boolean',
'clock_skew_seconds' => 'integer',
];
}
protected $hidden = [
'client_secret',
@ -32,9 +44,46 @@ 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<int, string>
*/
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';
}
}

View file

@ -304,6 +304,11 @@ public function cloudProviderTokens()
return $this->hasMany(CloudProviderToken::class);
}
public function integrationTokens()
{
return $this->hasMany(IntegrationToken::class);
}
public function sources()
{
$sources = collect([]);

View file

@ -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;
@ -507,12 +508,26 @@ 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.
*/
public function hasPassword(): bool
{
return ! empty($this->password);
}
public function requiresPasswordConfirmation(): bool
{
return $this->hasPassword() && ! $this->hasSsoIdentity();
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\IntegrationToken;
use App\Models\User;
class IntegrationTokenPolicy
{
public function viewAny(User $user): bool
{
return $user->isAdmin();
}
public function create(User $user): bool
{
return $user->isAdmin();
}
public function view(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
}
public function update(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
}
public function delete(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
}
}

View file

@ -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 Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
@ -22,12 +26,11 @@ public function register(): void
public function boot(): void
{
$this->configureCommands();
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
$this->configureGitHubHttp();
$this->configureOidcSocialite();
}
private function configureCommands(): void
@ -62,6 +65,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) {
@ -77,16 +98,5 @@ private function configureGitHubHttp(): void
])->baseUrl($api_url);
}
});
Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
$client = Http::withHeaders([
'Accept' => 'application/json',
])->baseUrl($api_url);
if ($access_token) {
$client = $client->withToken($access_token);
}
return $client;
});
}
}

View file

@ -15,6 +15,7 @@
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
@ -52,6 +53,7 @@
use App\Policies\GithubAppPolicy;
use App\Policies\GitlabAppPolicy;
use App\Policies\InstanceSettingsPolicy;
use App\Policies\IntegrationTokenPolicy;
use App\Policies\NotificationPolicy;
use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy;
@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider
// Cloud provider policies
CloudProviderToken::class => CloudProviderTokenPolicy::class,
IntegrationToken::class => IntegrationTokenPolicy::class,
CloudInitScript::class => CloudInitScriptPolicy::class,
Tag::class => TagPolicy::class,

View file

@ -48,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');
}
@ -61,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,
]);
});

View file

@ -0,0 +1,228 @@
<?php
namespace App\Services\Auth;
use App\Auth\Oidc\OidcUser;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthLoginService
{
public function login(string $provider, object $oauthUser, OauthSetting $oauthSetting): User
{
$email = strtolower(trim((string) $oauthUser->email));
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new HttpException(403, 'OAuth provider did not return a valid email address');
}
$user = $provider === 'oidc'
? $this->resolveOidcUser($oauthUser, $oauthSetting, $email)
: $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
Auth::login($user);
$team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team();
session(['currentTeam' => $user->currentTeam = $team]);
return $user;
}
private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
{
$provider = $oauthSetting->provider;
$providerUserId = $oauthUser->id ?? null;
if (
(! is_string($providerUserId) && ! is_int($providerUserId))
|| (is_string($providerUserId) && trim($providerUserId) === '')
) {
throw new HttpException(403, 'OAuth provider did not return a valid user ID');
}
$providerUserId = (string) $providerUserId;
$rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
$identityKey = [
'provider' => $provider,
'issuer' => $provider,
'provider_user_id' => $providerUserId,
];
try {
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User {
$identity = OauthIdentity::where($identityKey)->first();
if ($identity) {
$identity->update([
'email' => $email,
'raw_claims' => $rawClaims,
'last_login_at' => now(),
]);
return $identity->user;
}
$user = User::whereEmail($email)->first();
if (! $user) {
if (! $this->canCreateUser($oauthSetting)) {
throw new HttpException(403, 'Registration is disabled');
}
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
}
OauthIdentity::create([
'user_id' => $user->id,
'provider' => $provider,
'issuer' => $provider,
'provider_user_id' => $providerUserId,
'email' => $email,
'raw_claims' => $rawClaims,
'last_login_at' => now(),
]);
return $user;
});
} catch (UniqueConstraintViolationException $exception) {
return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
}
}
private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
{
$issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
? $oauthUser->issuer
: data_get($oauthUser->user, 'iss');
$subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
? $oauthUser->subject
: data_get($oauthUser->user, 'sub', $oauthUser->id);
$emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
|| data_get($oauthUser->user, 'email_verified') === true;
if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') {
throw new HttpException(403, 'OIDC provider did not return issuer and subject claims');
}
if ($oauthSetting->require_email_verified && ! $emailVerified) {
throw new HttpException(403, 'OIDC provider did not verify the email address');
}
$rawClaims = is_array($oauthUser->user ?? null) ? $oauthUser->user : [];
$identityKey = [
'provider' => 'oidc',
'issuer' => $issuer,
'provider_user_id' => $subject,
];
try {
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject, $emailVerified, $rawClaims, $identityKey): User {
$identity = OauthIdentity::where($identityKey)->first();
if ($identity) {
$identity->update([
'email' => $email,
'raw_claims' => $rawClaims,
'last_login_at' => now(),
]);
return $identity->user;
}
$user = User::whereEmail($email)->first();
// Linking a new OIDC identity to an existing local account by email
// is account takeover unless the provider attests the email. This
// guard is independent of the require_email_verified toggle, which
// only governs the broader login flow.
if ($user && ! $emailVerified) {
throw new HttpException(403, 'OIDC provider must verify the email address before linking to an existing account');
}
if (! $user) {
if (! $this->canCreateUser($oauthSetting)) {
throw new HttpException(403, 'Registration is disabled');
}
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
}
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => $issuer,
'provider_user_id' => $subject,
'email' => $email,
'raw_claims' => $rawClaims,
'last_login_at' => now(),
]);
return $user;
});
} catch (UniqueConstraintViolationException $exception) {
return OauthIdentity::where($identityKey)->first()?->user ?? throw $exception;
}
}
private function canCreateUser(OauthSetting $oauthSetting): bool
{
return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration;
}
private function createUser(string $name, string $email, OauthSetting $oauthSetting): User
{
if (User::count() === 0) {
$user = (new User)->forceFill([
'id' => 0,
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
]);
$user->save();
$team = $user->teams()->first() ?? Team::find(0);
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team, ['role' => 'owner']);
}
instanceSettings()->update(['is_registration_enabled' => false]);
return $user;
}
if ($oauthSetting->auto_join_root_team) {
return $this->createRootTeamOnlyUser($name, $email);
}
return User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
]);
}
private function createRootTeamOnlyUser(string $name, string $email): User
{
return DB::transaction(function () use ($name, $email) {
$rootTeam = Team::find(0);
if ($rootTeam === null) {
throw new HttpException(403, 'Root team is not available for OAuth user provisioning');
}
$user = User::withoutEvents(fn () => User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
]));
$user->teams()->attach($rootTeam, ['role' => 'member']);
return $user;
});
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class CloudflareTokenValidator
{
public function validate(string $token, array $capabilities): bool
{
$client = $this->client($token);
$verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify');
if (! $verification->successful() || $verification->json('result.status') !== 'active') {
return false;
}
if (in_array('dns', $capabilities, true)) {
$zones = $client->get('https://api.cloudflare.com/client/v4/zones', ['per_page' => 1]);
$zoneId = $zones->json('result.0.id');
if (! $zones->successful() || ! is_string($zoneId)) {
return false;
}
return $client->get("https://api.cloudflare.com/client/v4/zones/{$zoneId}/dns_records", [
'per_page' => 1,
])->successful();
}
return true;
}
private function client(string $token): PendingRequest
{
return Http::withToken($token)
->acceptJson()
->connectTimeout(5)
->timeout(10);
}
}

View file

@ -4553,7 +4553,7 @@ function formatContainerStatus(string $status): string
* Check if password confirmation should be skipped.
* Returns true if:
* - Two-step confirmation is globally disabled
* - User has no password (OAuth users)
* - User has no usable local password confirmation (including SSO users)
*
* Used by modal-confirmation.blade.php to determine if password step should be shown.
*
@ -4566,8 +4566,9 @@ function shouldSkipPasswordConfirmation(): bool
return true;
}
// Skip if user has no password (OAuth users)
if (! Auth::user()?->hasPassword()) {
// OAuth users may have an unusable generated password, so the linked
// identity is the source of truth for whether confirmation is possible.
if (! Auth::user()?->requiresPasswordConfirmation()) {
return true;
}
@ -4578,7 +4579,7 @@ function shouldSkipPasswordConfirmation(): bool
* Verify password for two-step confirmation.
* Skips verification if:
* - Two-step confirmation is globally disabled
* - User has no password (OAuth users)
* - User has no usable local password confirmation (including SSO users)
*
* @param mixed $password The password to verify (may be array if skipped by frontend)
* @param Component|null $component Optional Livewire component to add errors to

View file

@ -1,7 +1,13 @@
<?php
use App\Auth\Oidc\OidcConfig;
use App\Models\OauthSetting;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\BitbucketProvider;
use Laravel\Socialite\Two\GithubProvider;
use Laravel\Socialite\Two\GitlabProvider;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\Config;
function get_socialite_provider(string $provider)
{
@ -12,7 +18,7 @@ function get_socialite_provider(string $provider)
}
if ($provider === 'azure') {
$azure_config = new \SocialiteProviders\Manager\Config(
$azure_config = new Config(
$oauth_setting->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(

View file

@ -14,6 +14,7 @@
"php": "^8.4",
"danharrin/livewire-rate-limiting": "^2.2.1",
"doctrine/dbal": "^4.4.4",
"firebase/php-jwt": "7.1.0",
"guzzlehttp/guzzle": "^7.15.3",
"laravel/fortify": "^1.37.3",
"laravel/framework": "^12.65.0",

2
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "971daeb1b3078a36428c0fb56bb895b7",
"content-hash": "2d511da9e5e82eade5aa7e5094c888ae",
"packages": [
{
"name": "aws/aws-crt-php",

View file

@ -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'),

View file

@ -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
{

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->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',
]);
});
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_identities', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('integration_tokens', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('provider');
$table->string('name');
$table->text('token');
$table->json('capabilities');
$table->timestamps();
$table->index(['team_id', 'provider']);
});
}
public function down(): void
{
Schema::dropIfExists('integration_tokens');
}
};

View file

@ -23,6 +23,7 @@ public function run(): void
'github',
'gitlab',
'google',
'oidc',
'authentik',
'infomaniak',
'zitadel',

View file

@ -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',
]);

View file

@ -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?",

View file

@ -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?",

View file

@ -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?",

5
public/svgs/oidc.svg Normal file
View file

@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>OpenID Connect</title>
<path fill-rule="evenodd" d="M10 5.5a7.5 7.5 0 1 0 7.5 7.5c0-.9-.16-1.77-.45-2.57l-2.78 1.04c.15.48.23.99.23 1.53a4.5 4.5 0 1 1-4.5-4.5c.54 0 1.05.09 1.53.26l1.05-2.8A7.48 7.48 0 0 0 10 5.5Z" clip-rule="evenodd"/>
<path d="M16.5 1 12 5.5h3V10h3V5.5h3L16.5 1Z"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View file

@ -80,11 +80,15 @@ class="auth-tooltip max-w-xs whitespace-normal">
@if ($enabled_oauth_providers->isNotEmpty())
<div class="auth-divider"><span>Or continue with</span></div>
<div class="grid gap-2 sm:grid-cols-2">
<div class="flex flex-col gap-2">
@foreach ($enabled_oauth_providers as $provider_setting)
<x-forms.button class="w-full justify-center" type="button"
onclick="document.location.href='/auth/{{ $provider_setting->provider }}/redirect'">
{{ __("auth.login.$provider_setting->provider") }}
@if ($provider_setting->provider !== 'oidc')
<img class="size-5 shrink-0 dark:invert"
src="{{ asset('svgs/'.$provider_setting->provider.'.svg') }}" alt="" aria-hidden="true">
@endif
{{ $provider_setting->loginLabel() }}
</x-forms.button>
@endforeach
</div>

View file

@ -12,6 +12,12 @@
'active' => request()->routeIs('security.cloud-tokens*'),
'icon' => 'cloud',
] : null,
auth()->user()?->can('viewAny', App\Models\IntegrationToken::class) ? [
'label' => 'Integration Tokens',
'route' => 'security.integration-tokens',
'active' => request()->routeIs('security.integration-tokens'),
'icon' => 'network',
] : null,
auth()->user()?->can('viewAny', App\Models\CloudInitScript::class) ? [
'label' => 'Cloud-Init Scripts',
'route' => 'security.cloud-init-scripts',

View file

@ -12,6 +12,24 @@
'active' => $activeMenu === 'advanced',
'icon' => 'grid',
],
[
'label' => 'Authentication',
'route' => 'settings.oauth',
'active' => $activeMenu === 'oauth',
'icon' => 'keys',
],
[
'label' => 'Transactional Email',
'route' => 'settings.email',
'active' => $activeMenu === 'email',
'icon' => 'notifications',
],
[
'label' => 'Instance Backup',
'route' => 'settings.backup',
'active' => $activeMenu === 'backup',
'icon' => 'database',
],
[
'label' => 'Updates',
'route' => 'settings.updates',

View file

@ -134,15 +134,22 @@ class="h-full w-full object-cover">
<div class="flex items-end gap-2">
<x-forms.input id="email" label="Email" readonly />
<x-forms.button @click="openEmailModal()" type="button"
x-bind:disabled="emailModalOpen">
:disabled="$uses_sso" x-bind:disabled="emailModalOpen || @js($uses_sso)">
Change
</x-forms.button>
</div>
</div>
</section>
</form>
</section>
</form>
<template x-teleport="body">
@if ($uses_sso)
<x-callout type="info" title="Email managed by SSO">
Signed in with SSO @if ($sso_provider_label) ({{ $sso_provider_label }}) @endif. Email is managed by your SSO provider.
</x-callout>
@endif
@if (! $uses_sso)
<template x-teleport="body">
<div x-show="emailModalOpen" x-cloak
class="fixed inset-0 z-99 flex h-screen w-screen items-center justify-center p-4">
<div class="absolute inset-0 h-full w-full bg-black/55 backdrop-blur-[3px]"></div>
@ -191,7 +198,8 @@ class="icon-button shrink-0" aria-label="Close">
@endif
</div>
</div>
</template>
</template>
@endif
<form wire:submit="resetPassword">
<section class="application-settings-section">

View file

@ -0,0 +1,52 @@
<div class="w-full">
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="save">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="name" label="Token name" />
<x-forms.input readonly label="Provider" value="Cloudflare" />
<div class="lg:col-span-2">
<x-forms.input type="password" id="newToken" label="New API token"
placeholder="Leave blank to keep the current token"
helper="Paste a replacement token to rotate this credential." />
</div>
</div>
<fieldset>
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
wire:model.live="capabilities" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Manage Cloudflare DNS records.
</p>
</div>
@error('capabilities')
<span class="text-xs text-red-500">{{ $message }}</span>
@enderror
</fieldset>
@if (in_array('dns', $capabilities, true))
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
<ul class="list-inside list-disc">
<li>Zone - DNS - Edit</li>
<li>Zone - Zone - Read</li>
</ul>
<a href="https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management"
target="_blank" rel="noopener noreferrer"
class="font-medium text-coollabs hover:underline dark:text-warning">
Create a replacement token in Cloudflare
</a>
</div>
@endif
<div class="flex items-center justify-between gap-2 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-modal-confirmation title="Delete integration token?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['This integration token will be permanently deleted.']"
confirmationText="{{ $integrationToken->name }}" :confirmWithPassword="false"
step2ButtonText="Delete token" />
<x-forms.button type="submit" wire:target="save" isHighlighted>
Validate and save
</x-forms.button>
</div>
</form>
</div>

View file

@ -0,0 +1,49 @@
<div class="w-full">
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken">
<x-forms.listbox required id="provider" label="Provider" :options="[
['value' => 'cloudflare', 'label' => 'Cloudflare'],
]" />
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="name" label="Token name" placeholder="Production DNS" />
<x-forms.input required type="password" id="token" label="API token"
placeholder="Paste the provider token" />
</div>
<fieldset>
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
wire:model.live="capabilities" />
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
Manage Cloudflare DNS records.
</p>
</div>
@error('capabilities')
<span class="text-xs text-red-500">{{ $message }}</span>
@enderror
</fieldset>
@if (in_array('dns', $capabilities, true))
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
<ul class="list-inside list-disc">
<li>Zone - DNS - Edit</li>
<li>Zone - Zone - Read</li>
</ul>
<p>Limit zone resources to the zones Coolify should manage.</p>
<a href="https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management"
target="_blank" rel="noopener noreferrer"
class="font-medium text-coollabs hover:underline dark:text-warning">
Create this token in Cloudflare
</a>
</div>
@endif
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button type="submit" wire:target="addToken" isHighlighted>
Validate and add
</x-forms.button>
</div>
</form>
</div>

View file

@ -0,0 +1,84 @@
<div>
<x-slot:title>
Integration Tokens | Coolify
</x-slot>
<x-security.settings-layout>
<div class="application-settings-form">
<x-application.settings-section title="Integration tokens"
description="Credentials used by third-party integrations such as DNS providers." flush>
<x-slot:actions>
@can('create', App\Models\IntegrationToken::class)
<x-modal-input title="New Integration Token">
<x-slot:content>
<button type="button" class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New token
</button>
</x-slot:content>
<livewire:security.integration-token-form :modal_mode="true"
wire:key="new-integration-token" />
</x-modal-input>
@endcan
</x-slot:actions>
@if ($tokens->isEmpty())
<x-empty title="No integration tokens"
description="Add a provider token to connect a third-party integration."
icon-name="keys" size="sm" />
@else
<div class="divide-y divide-neutral-200 dark:divide-white/[0.07]">
@foreach ($tokens as $savedToken)
<div wire:key="integration-token-{{ $savedToken->id }}"
x-data="{
visible: true,
tokenName: @js($savedToken->name),
tokenCapabilities: @js($savedToken->capabilities),
}"
x-show="visible"
x-on:integration-token-updated.window="
if ($event.detail.uuid === @js($savedToken->uuid)) {
tokenName = $event.detail.name;
tokenCapabilities = $event.detail.capabilities;
}
"
x-on:integration-token-deleted.window="
if ($event.detail.uuid === @js($savedToken->uuid)) visible = false
">
<x-modal-input title="Edit Integration Token" isFullWidth :wireIgnore="false"
:contentClicks="false"
class="border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<x-slot:content>
<div class="grid min-h-14 w-full grid-cols-[minmax(0,1fr)_8rem_minmax(0,1fr)_2rem] items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.025]">
<div class="min-w-0">
<h3 class="truncate text-[13px]! font-semibold! text-black dark:text-fg">
<span x-text="tokenName"></span>
</h3>
</div>
<div class="text-center text-[12px] text-neutral-500 dark:text-fg-dim">
{{ ucfirst($savedToken->provider) }}
</div>
<div class="flex flex-wrap gap-1">
<template x-for="capability in tokenCapabilities" :key="capability">
<span x-text="capability"
class="rounded-full bg-neutral-100 px-2 py-0.5 text-[10px] font-medium uppercase text-neutral-600 dark:bg-white/[0.06] dark:text-fg-dim"></span>
</template>
</div>
<button type="button" class="icon-button" title="Edit integration token"
:aria-label="`Edit ${tokenName}`" @click="modalOpen=true">
<x-reicon name="settings" class="size-3.5" />
</button>
</div>
</x-slot:content>
<livewire:security.integration-token-editor
:integration_token_uuid="$savedToken->uuid"
:key="'integration-token-editor-'.$savedToken->uuid" />
</x-modal-input>
</div>
@endforeach
</div>
@endif
</x-application.settings-section>
</div>
</x-security.settings-layout>
</div>

View file

@ -35,8 +35,8 @@ class="server-settings-workspace application-settings-workspace mt-4 grid w-full
</x-slot:actions>
<x-callout type="info" title="Supported package managers">
Automated package discovery currently supports apt, dnf, and zypper. Weekly status notifications
can be managed from
Automated package discovery currently supports apk, apt, dnf, pacman, and zypper. Weekly status
notifications can be managed from
<a class="font-medium underline" href="{{ route('notifications.email') }}"
{{ wireNavigate() }}>notification settings</a>.
</x-callout>

View file

@ -5,76 +5,126 @@
<x-settings.layout>
<x-slot:submenu>
<div
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || '{{ $oauth_settings_map[0]['provider'] ?? '' }}' }"
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
<nav aria-label="OAuth providers"
class="grid gap-0.5 py-1">
@foreach ($oauth_settings_map as $oauth_setting)
@php
$provider = $oauth_setting['provider'];
$providerLabel = str($provider)->headline();
@endphp
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
<span class="menu-item-icon bg-current"
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
<span class="menu-item-label">{{ $providerLabel }}</span>
</a>
@endforeach
</nav>
</div>
<div
x-data="{ activeProvider: location.hash.slice(1).replace('-oauth-section', '') || @js($selectedProvider ?? array_key_first($oauth_settings_map)) }"
@hashchange.window="activeProvider = location.hash.slice(1).replace('-oauth-section', '')">
<nav aria-label="OAuth providers" class="grid gap-0.5 py-1">
@foreach ($oauth_settings_map as $provider => $oauth_setting)
<a href="#{{ $provider }}-oauth-section" class="menu-item min-h-8! py-1! text-[12px]!"
:class="{ 'menu-item-active': activeProvider === '{{ $provider }}' }"
@click.prevent="activeProvider = '{{ $provider }}'; history.replaceState(null, '', '#{{ $provider }}-oauth-section'); window.scrollToSettingsSection?.('{{ $provider }}-oauth-section')">
<span class="menu-item-icon bg-current"
style="mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat; -webkit-mask: url('{{ asset('svgs/' . $provider . '.svg') }}') center / contain no-repeat;"></span>
<span class="menu-item-label">{{ $oauth_setting['label'] }}</span>
</a>
@endforeach
</nav>
</div>
</x-slot:submenu>
<form wire:submit="submit" class="application-settings-form flex w-full min-w-0 flex-col gap-6">
<x-unsaved-bar action="submit" />
@foreach ($oauth_settings_map as $oauth_setting)
@php
$provider = $oauth_setting['provider'];
$providerLabel = str($provider)->headline();
@endphp
<x-application.settings-section title="Registration"
description="Control password registration when an OAuth provider is available.">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="disable_registration_when_oauth_enabled"
label="Disable password registration when OAuth is enabled"
helper="OAuth providers can still create users when registration is enabled for that provider."
instantSave="saveRegistrationPolicy" />
</x-application.settings-section>
@foreach ($oauth_settings_map as $provider => $oauth_setting)
<x-application.settings-section id="{{ $provider }}-oauth-section" class="scroll-mt-28"
title="{{ $providerLabel }}">
title="{{ $oauth_setting['label'] }}">
<x-slot:actions>
<div x-data="{ enabled: @js((bool) $oauth_setting['enabled']), provider: @js($provider) }">
<x-forms.button type="button" :isHighlighted="!$oauth_setting['enabled']"
<x-forms.button canGate="update" :canResource="$settings" type="button"
:isHighlighted="!$oauth_setting['enabled']"
x-on:click="
if (!enabled) {
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
.find(field => !field.checkValidity());
if (invalidField) { invalidField.reportValidity(); return; }
}
$wire.toggleProvider(provider);
">
if (!enabled) {
const invalidField = [...$el.closest('section').querySelectorAll('[required]')]
.find(field => !field.checkValidity());
if (invalidField) { invalidField.reportValidity(); return; }
}
$wire.toggleProvider(provider);
">
{{ $oauth_setting['enabled'] ? 'Disable' : 'Enable' }}
</x-forms.button>
</div>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="oauth_settings_map.{{ $provider }}.redirect_uri"
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_id"
label="Client ID" required />
<x-forms.input id="oauth_settings_map.{{ $provider }}.client_secret"
type="password" label="Client secret" autocomplete="new-password" required />
<div class="grid gap-4 lg:grid-cols-2">
@if ($provider === 'oidc')
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.redirect_uri"
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.base_url" label="Issuer URL" required
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses it to discover the authorization, token, userinfo, and JWKS endpoints." />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_id" label="Client ID" required />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_secret" type="password"
label="Client secret" autocomplete="new-password" required />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.scopes" label="Scopes"
helper="Must include openid. Common scopes are openid email profile groups." />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.clock_skew_seconds" type="number"
label="Clock skew (seconds)" />
<div class="lg:col-span-2">
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.custom_label" label="Login button label"
placeholder="Login with SSO" />
</div>
@else
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.redirect_uri"
placeholder="{{ route('auth.callback', $provider) }}" label="Redirect URI" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_id" label="Client ID" required />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.client_secret" type="password"
label="Client secret" autocomplete="new-password" required />
@endif
@if ($provider === 'azure')
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
label="Tenant" required />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.tenant" label="Tenant" required />
@endif
@if ($provider === 'google')
<x-forms.input id="oauth_settings_map.{{ $provider }}.tenant"
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.tenant"
helper="Optional hosted domain supplied to Google as a login hint."
label="Hosted domain" />
@endif
@if (in_array($provider, ['authentik', 'clerk', 'zitadel', 'gitlab'], true))
<x-forms.input id="oauth_settings_map.{{ $provider }}.base_url"
label="Base URL" :required="in_array($provider, ['authentik', 'clerk'], true)" />
<x-forms.input canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.base_url" label="Base URL"
:required="in_array($provider, ['authentik', 'clerk'], true)" />
@endif
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
@if ($provider === 'oidc')
<x-forms.checkbox canGate="update" :canResource="$settings"
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." />
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.require_email_verified"
label="Require verified email" />
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.use_pkce" label="Use PKCE" />
@endif
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $provider }}.auto_join_root_team"
label="Auto-join new users to Root team"
helper="Add newly-created OAuth users to the Root team as members without creating a personal team." />
</div>
</x-application.settings-section>
@endforeach

View file

@ -13,12 +13,19 @@
<x-application.settings-section id="access-section" title="Access">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="is_registration_enabled" label="Registration"
<x-forms.listbox id="is_registration_enabled" label="Registration"
helper="Allow users to create their own account. When disabled, only administrators can create accounts."
onChange="instantSave" :options="[
['value' => true, 'label' => 'Anyone can register'],
['value' => false, 'label' => 'Registration disabled'],
]" />
]" />
<x-forms.listbox canGate="update" :canResource="$settings"
id="disable_registration_when_oauth_enabled" label="Password registration with OAuth"
helper="Hide password registration whenever at least one OAuth provider is enabled."
onChange="instantSave" :options="[
['value' => false, 'label' => 'Allow password registration'],
['value' => true, 'label' => 'Disable when OAuth is enabled'],
]" />
<x-forms.listbox id="disable_two_step_confirmation" label="Destructive action confirmation"
helper="Choose whether destructive actions require password and text confirmation."
onChange="instantSave" :options="[

View file

@ -47,6 +47,7 @@
use App\Livewire\Security\CloudInitScripts;
use App\Livewire\Security\CloudProviderToken\Show as SecurityCloudProviderTokenShow;
use App\Livewire\Security\CloudTokens;
use App\Livewire\Security\IntegrationTokens;
use App\Livewire\Security\PrivateKey\Index as SecurityPrivateKeyIndex;
use App\Livewire\Security\PrivateKey\Show as SecurityPrivateKeyShow;
use App\Livewire\Server\Advanced as ServerAdvanced;
@ -164,6 +165,9 @@
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
Route::get('/settings/oauth/{provider}', SettingsOauth::class)
->where('provider', '[A-Za-z0-9_-]+')
->name('settings.oauth.provider');
Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
Route::get('/profile', ProfileIndex::class)->name('profile');
@ -385,6 +389,7 @@
Route::get('/security/private-key/{private_key_uuid}', SecurityPrivateKeyShow::class)->name('security.private-key.show');
Route::get('/security/cloud-tokens', CloudTokens::class)->name('security.cloud-tokens');
Route::get('/security/integration-tokens', IntegrationTokens::class)->name('security.integration-tokens');
Route::get('/security/cloud-tokens/{cloud_token_uuid}', SecurityCloudProviderTokenShow::class)->name('security.cloud-tokens.show');
Route::get('/security/cloud-init-scripts', CloudInitScripts::class)->name('security.cloud-init-scripts');
Route::get('/security/cloud-init-scripts/{cloud_init_script_uuid}', SecurityCloudInitScriptShow::class)->name('security.cloud-init-scripts.show');

View file

@ -64,7 +64,7 @@
"category": "productivity",
"logo": "svgs/alexandrie.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-07-07T13:24:35+02:00",
"template_last_updated_at": "2026-04-05T13:36:24+02:00",
"port": "8200"
},
"anythingllm": {
@ -1361,7 +1361,7 @@
"category": "productivity",
"logo": "svgs/espocrm.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-07-03T15:15:43+03:00",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"port": "80"
},
"evolution-api": {

View file

@ -64,7 +64,7 @@
"category": "productivity",
"logo": "svgs/alexandrie.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-07-07T13:24:35+02:00",
"template_last_updated_at": "2026-04-05T13:36:24+02:00",
"port": "8200"
},
"anythingllm": {
@ -1361,7 +1361,7 @@
"category": "productivity",
"logo": "svgs/espocrm.svg",
"minversion": "0.0.0",
"template_last_updated_at": "2026-07-03T15:15:43+03:00",
"template_last_updated_at": "2026-04-06T11:35:16-05:00",
"port": "80"
},
"evolution-api": {

View file

@ -0,0 +1,179 @@
<?php
use App\Livewire\Notifications\Discord;
use App\Livewire\Notifications\Email;
use App\Livewire\Notifications\Pushover;
use App\Livewire\Notifications\Slack;
use App\Livewire\Notifications\Telegram;
use App\Livewire\Notifications\Webhook;
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function actingAsEnableActionOwner(): array
{
$team = Team::factory()->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);
});

View file

@ -0,0 +1,45 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});

View file

@ -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'));

View file

@ -1,25 +1,35 @@
<?php
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\User;
use App\Services\Auth\OauthLoginService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Once;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpKernel\Exception\HttpException;
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,
]);
});
@ -46,6 +56,75 @@
$response->assertRedirect('/');
$this->assertAuthenticatedAs($user);
expect(User::count())->toBe(1);
expect(OauthIdentity::where([
'user_id' => $user->id,
'provider' => 'google',
'provider_user_id' => 'google-user-id',
])->exists())->toBeTrue();
});
it('never moves an existing oauth identity when the provider email changes', function () {
config()->set('app.maintenance.driver', 'file');
$identityOwner = User::factory()->create(['email' => 'old@example.com']);
$otherUser = User::factory()->create(['email' => 'new@example.com']);
$identity = OauthIdentity::create([
'user_id' => $identityOwner->id,
'provider' => 'google',
'issuer' => 'google',
'provider_user_id' => 'google-user-id',
'email' => 'old@example.com',
]);
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
$provider->shouldReceive('user')->once()->andReturn((object) [
'email' => 'new@example.com',
'name' => 'Example User',
'id' => 'google-user-id',
]);
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
$this->get(route('auth.callback', 'google'))->assertRedirect('/');
$this->assertAuthenticatedAs($identityOwner);
expect($identity->refresh()->user_id)->toBe($identityOwner->id)
->and($identity->email)->toBe('new@example.com')
->and($identity->user_id)->not->toBe($otherUser->id);
});
it('continues oauth login when another request creates the identity first', function () {
$user = User::factory()->create(['email' => 'race@example.com']);
$eventName = 'eloquent.creating: '.OauthIdentity::class;
Event::listen($eventName, function (OauthIdentity $identity): void {
$attributes = $identity->getAttributes();
DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes));
throw new UniqueConstraintViolationException(
DB::getDefaultConnection(),
'insert into oauth_identities',
[],
new PDOException('duplicate identity'),
);
});
try {
$resolvedUser = app(OauthLoginService::class)->login('google', (object) [
'email' => 'race@example.com',
'name' => 'Race User',
'id' => 'google-race-id',
], OauthSetting::where('provider', 'google')->firstOrFail());
} finally {
Event::forget($eventName);
}
expect($resolvedUser->is($user))->toBeTrue()
->and(OauthIdentity::where('provider_user_id', 'google-race-id')->count())->toBe(1);
$this->assertAuthenticatedAs($user);
});
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
@ -76,4 +155,37 @@
})->with([
'null email' => [null],
'blank email' => [' '],
'malformed email' => ['not-an-email'],
'missing domain' => ['user@'],
]);
it('rejects oauth logins when the provider does not return a valid user id', function (mixed $invalidId) {
$oauthUser = (object) [
'email' => 'user@example.edu',
'name' => 'Example User',
];
if ($invalidId !== 'missing') {
$oauthUser->id = $invalidId;
}
try {
app(OauthLoginService::class)->login('google', $oauthUser, OauthSetting::where('provider', 'google')->firstOrFail());
} catch (HttpException $exception) {
expect($exception->getStatusCode())->toBe(403)
->and(OauthIdentity::count())->toBe(0)
->and(User::count())->toBe(0);
return;
}
$this->fail('Expected an invalid OAuth provider user ID to be rejected.');
})->with([
'null id' => [null],
'missing id' => ['missing'],
'blank id' => [' '],
'non-scalar id' => [[]],
'true id' => [true],
'false id' => [false],
'float id' => [1.0],
]);

View file

@ -0,0 +1,52 @@
<?php
use App\Actions\Fortify\CreateNewUser;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate([
'id' => 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');
});

View file

@ -0,0 +1,275 @@
<?php
use App\Auth\Oidc\OidcUser;
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\Team;
use App\Models\User;
use App\Services\Auth\OauthLoginService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Laravel\Socialite\Facades\Socialite;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->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('continues oidc login when another request creates the identity first', function () {
$user = User::factory()->create(['email' => 'race@example.com']);
$eventName = 'eloquent.creating: '.OauthIdentity::class;
Event::listen($eventName, function (OauthIdentity $identity): void {
$attributes = $identity->getAttributes();
DB::afterRollBack(fn () => DB::table('oauth_identities')->insert($attributes));
throw new UniqueConstraintViolationException(
DB::getDefaultConnection(),
'insert into oauth_identities',
[],
new PDOException('duplicate identity'),
);
});
try {
$resolvedUser = app(OauthLoginService::class)->login('oidc', (new OidcUser)->setRaw([
'iss' => 'https://idp.example.com',
'sub' => 'oidc-race-id',
'email' => 'race@example.com',
'email_verified' => true,
'name' => 'Race User',
])->map([
'id' => 'oidc-race-id',
'name' => 'Race User',
'email' => 'race@example.com',
]), OauthSetting::where('provider', 'oidc')->firstOrFail());
} finally {
Event::forget($eventName);
}
expect($resolvedUser->is($user))->toBeTrue()
->and(OauthIdentity::where('provider_user_id', 'oidc-race-id')->count())->toBe(1);
$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('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']);
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']);
$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']);
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]);
$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;
});
});

View file

@ -0,0 +1,91 @@
<?php
use App\Livewire\Profile\Index as ProfileIndex;
use App\Models\OauthIdentity;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows when the profile user signed in with sso', function () {
$user = User::factory()->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();
});

View file

@ -0,0 +1,253 @@
<?php
use App\Livewire\Security\IntegrationTokenEditor;
use App\Livewire\Security\IntegrationTokenForm;
use App\Livewire\Security\IntegrationTokens;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->whereKey(0)->exists()) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
Once::flush();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
});
test('a cloudflare dns token is validated with read only requests before it is saved', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => true,
'result' => ['status' => 'active'],
]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id']],
]),
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
'success' => true,
'result' => [],
]),
]);
Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
->set('provider', 'cloudflare')
->set('name', 'Production DNS')
->set('token', 'cloudflare-token')
->set('capabilities', ['dns'])
->call('addToken')
->assertHasNoErrors()
->assertDispatched('close-modal');
$this->assertDatabaseHas('integration_tokens', [
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
]);
Http::assertSentCount(3);
Http::assertSent(fn ($request) => $request->method() === 'GET'
&& $request->url() === 'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1');
});
test('a cloudflare token is not saved when scope validation fails', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => true,
'result' => ['status' => 'active'],
]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
'success' => false,
'errors' => [['message' => 'Authentication error']],
], 403),
]);
Livewire::test(IntegrationTokenForm::class)
->set('name', 'Invalid DNS token')
->set('token', 'cloudflare-token')
->set('capabilities', ['dns'])
->call('addToken')
->assertDispatched('error');
$this->assertDatabaseCount('integration_tokens', 0);
});
test('at least one capability is required when adding a cloudflare token', function () {
Livewire::test(IntegrationTokenForm::class)
->set('name', 'Account token')
->set('token', 'cloudflare-token')
->set('capabilities', [])
->call('addToken')
->assertHasErrors(['capabilities' => 'required']);
$this->assertDatabaseCount('integration_tokens', 0);
Http::assertNothingSent();
});
test('integration tokens page lists saved provider and capabilities', function () {
IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'secret',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokens::class)
->assertSee('Production DNS')
->assertSee('Cloudflare')
->assertSee('DNS');
});
test('cloudflare dns scope guidance and token creation link are shown', function () {
Livewire::test(IntegrationTokenForm::class)
->set('capabilities', ['dns'])
->assertSee('Zone - DNS - Edit')
->assertSee('Zone - Zone - Read')
->assertSeeHtml('https://dash.cloudflare.com/profile/api-tokens?permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D&amp;accountId=%2A&amp;zoneId=all&amp;name=Coolify%20DNS%20Management');
expect(file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php')))
->toContain('permissionGroupKeys=%5B%7B%22key%22%3A%22dns%22%2C%22type%22%3A%22edit%22%7D%5D');
});
test('capability selection uses the shared checkbox component', function () {
$view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
expect($view)
->toContain('<x-forms.checkbox')
->toContain('class="mt-3 rounded-lg border')
->not->toContain('<input type="checkbox"');
});
test('submit button uses the shared highlighted loading state', function () {
$view = file_get_contents(resource_path('views/livewire/security/integration-token-form.blade.php'));
expect($view)
->toContain('wire:target="addToken" isHighlighted')
->not->toContain('class="button-highlighted"');
});
test('saved integration token rows render modal editors with a gear button', function () {
IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokens::class)
->assertSee('Edit Integration Token')
->assertSee('Production DNS')
->assertSeeHtml(':aria-label="`Edit ${tokenName}`"');
});
test('an integration token can be rotated after validating its capabilities', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => true,
'result' => ['status' => 'active'],
]),
'https://api.cloudflare.com/client/v4/zones?per_page=1' => Http::response([
'success' => true,
'result' => [['id' => 'zone-id']],
]),
'https://api.cloudflare.com/client/v4/zones/zone-id/dns_records?per_page=1' => Http::response([
'success' => true,
'result' => [],
]),
]);
$savedToken = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->set('name', 'Rotated DNS')
->set('newToken', 'rotated-token')
->call('save')
->assertHasNoErrors()
->assertDispatched('success');
$savedToken->refresh();
expect($savedToken->name)->toBe('Rotated DNS')
->and($savedToken->token)->toBe('rotated-token');
});
test('leaving the token field blank keeps the existing integration token', function () {
Http::fake();
$savedToken = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->set('name', 'Renamed DNS')
->set('newToken', '')
->call('save')
->assertHasNoErrors();
$savedToken->refresh();
expect($savedToken->name)->toBe('Renamed DNS')
->and($savedToken->token)->toBe('original-token');
Http::assertNothingSent();
});
test('an invalid replacement does not rotate the integration token', function () {
Http::fake([
'https://api.cloudflare.com/client/v4/user/tokens/verify' => Http::response([
'success' => false,
], 403),
]);
$savedToken = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'cloudflare',
'name' => 'Production DNS',
'token' => 'original-token',
'capabilities' => ['dns'],
]);
Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid])
->set('newToken', 'invalid-token')
->call('save')
->assertDispatched('error');
expect($savedToken->fresh()->token)->toBe('original-token');
});
test('editor updates its row without rerendering the teleported parent modal', function () {
$component = file_get_contents(app_path('Livewire/Security/IntegrationTokenEditor.php'));
expect($component)
->toContain("'integration-token-updated'")
->toContain("'integration-token-deleted'")
->not->toContain('integrationTokenChanged');
});

View file

@ -8,6 +8,7 @@
'security/private-key/index.blade.php',
'security/private-key/show.blade.php',
'security/cloud-tokens.blade.php',
'security/integration-tokens.blade.php',
'security/cloud-provider-token/show.blade.php',
'security/cloud-init-scripts.blade.php',
'security/cloud-init-script/show.blade.php',
@ -22,6 +23,7 @@
->toContain('application-settings-navigation')
->toContain("'label' => 'Private Keys'")
->toContain("'label' => 'Cloud Tokens'")
->toContain("'label' => 'Integration Tokens'")
->toContain("'label' => 'Cloud-Init Scripts'")
->toContain("'label' => 'API Tokens'");

View file

@ -0,0 +1,64 @@
<?php
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});

View file

@ -0,0 +1,52 @@
<?php
it('keeps backup and transactional email out of the settings top navigation', function () {
$this->blade('<x-settings.navbar />')
->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('<x-settings.sidebar activeMenu="backup" />')
->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('<x-settings.sidebar activeMenu="backup" />')
->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
->toContain('<x-settings.sidebar activeMenu="email" />');
});
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('<div class="pb-4">Instance backup configuration for Coolify instance.</div>')
->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
->not->toContain('class="flex flex-col gap-2 pb-4"')
->toContain('<div class="pb-4">Instance wide email settings for password resets, invitations, etc.</div>');
});
it('uses instance backup as the backup settings label', function () {
expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php')))
->toContain('<span class="menu-item-label">Instance Backup</span>')
->not->toContain('<span class="menu-item-label">Backup</span>')
->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->toContain('<h2>Instance Backup</h2>')
->toContain('Instance backup configuration for Coolify instance.')
->not->toContain('<h2>Backup</h2>');
});

View file

@ -0,0 +1,277 @@
<?php
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Livewire\SettingsOauth;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function actingAsInstanceAdmin(): User
{
$team = Team::forceCreate(['id' => 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 () {
$this->withoutVite();
config()->set('app.maintenance.driver', 'file');
InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]);
Once::flush();
OauthSetting::create(['provider' => 'oidc']);
OauthSetting::create(['provider' => 'authentik']);
OauthSetting::create(['provider' => 'bitbucket']);
});
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('Authentication')
->assertSee('Registration')
->assertSee('Authentik')
->assertSee('Bitbucket')
->assertSee('OpenID Connect')
->assertSee('Disable password registration when OAuth is enabled')
->assertSee('Client secret')
->assertSee('application-settings-form', false)
->assertDontSee(route('settings.oauth.provider', 'authentik'), false);
});
it('lists openid connect before the other oauth providers', function () {
actingAsInstanceAdmin();
$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 () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee("wire:click='saveRegistrationPolicy'", false)
->assertDontSee('Save</button>', false);
Livewire::test(SettingsOauth::class)
->set('disable_registration_when_oauth_enabled', true)
->call('saveRegistrationPolicy')
->assertHasNoErrors()
->assertDispatched('success');
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});
it('shows oidc fields with a naked okta issuer url example', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('OpenID Connect')
->assertSee('https://example.okta.com', false)
->assertDontSee('/oauth2/default', false);
});
it('groups oidc fields in the expected desktop order', function () {
$view = file_get_contents(resource_path('views/livewire/settings-oauth.blade.php'));
$fields = [
'redirect_uri',
'base_url',
'client_id',
'client_secret',
'scopes',
'clock_skew_seconds',
'custom_label',
];
$positions = array_map(
fn (string $field): int|false => strpos($view, "id=\"oauth_settings_map.{{ \$provider }}.$field\""),
$fields,
);
expect($positions)->not->toContain(false)
->and($positions)->toBe(collect($positions)->sort()->values()->all())
->and($view)->toContain('<div class="lg:col-span-2">');
});
it('shows provider enable controls as settings section actions', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Enable')
->assertDontSee('label="Enabled"', false)
->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false);
});
it('stacks oidc option checkboxes vertically', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get(route('settings.oauth'))
->assertSuccessful()
->assertSee('Allow OIDC user creation')
->assertSee('Require verified email')
->assertSee('Use PKCE')
->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false);
});
it('does not show unknown oauth providers', function () {
actingAsInstanceAdmin();
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
->get('/settings/oauth/unknown')
->assertNotFound();
});
it('defaults oidc user creation and verified email requirement to enabled', function () {
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->allow_registration)->toBeTrue()
->and($setting->require_email_verified)->toBeTrue()
->and($setting->auto_join_root_team)->toBeFalse();
});
it('persists oidc oauth settings from livewire', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class)
->set('oauth_settings_map.oidc.enabled', true)
->set('oauth_settings_map.oidc.client_id', 'client-id')
->set('oauth_settings_map.oidc.client_secret', 'secret')
->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback')
->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
->set('oauth_settings_map.oidc.scopes', 'openid email profile groups')
->set('oauth_settings_map.oidc.custom_label', 'Login with Okta')
->set('oauth_settings_map.oidc.allow_registration', true)
->set('oauth_settings_map.oidc.auto_join_root_team', true)
->set('oauth_settings_map.oidc.require_email_verified', true)
->set('disable_registration_when_oauth_enabled', true)
->call('submit')
->assertHasNoErrors();
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->enabled)->toBeTrue()
->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback')
->and($setting->base_url)->toBe('https://idp.example.com')
->and($setting->custom_label)->toBe('Login with Okta')
->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
->and($setting->allow_registration)->toBeTrue()
->and($setting->auto_join_root_team)->toBeTrue();
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});
it('saves only the selected provider from provider pages', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url')
->set('oauth_settings_map.authentik.enabled', true)
->set('oauth_settings_map.authentik.client_id', 'authentik-client')
->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
->call('submit')
->assertHasNoErrors();
$setting = OauthSetting::where('provider', 'authentik')->first();
expect($setting->enabled)->toBeTrue()
->and($setting->client_id)->toBe('authentik-client')
->and($setting->base_url)->toBe('https://authentik.example.com');
});
it('validates oidc url fields before saving', function (string $field, string $value) {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class)
->set('oauth_settings_map.oidc.client_id', 'client-id')
->set('oauth_settings_map.oidc.client_secret', 'secret')
->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
->set("oauth_settings_map.oidc.$field", $value)
->call('submit')
->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']);
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->{$field})->toBeNull();
})->with([
'invalid redirect uri' => ['redirect_uri', 'not-a-url'],
'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'],
'invalid issuer url' => ['base_url', 'not-a-url'],
'non-http issuer url' => ['base_url', 'ftp://idp.example.com'],
]);
it('does not enable oidc without required fields', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class)
->set('oauth_settings_map.oidc.enabled', true)
->call('instantSave', 'oidc')
->assertDispatched('error');
expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse();
});
it('keeps provider disabled in the ui when enable validation fails', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->call('toggleProvider', 'authentik')
->assertDispatched('error')
->assertSet('oauth_settings_map.authentik.enabled', false);
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
});
it('disables an enabled provider gracefully when required fields become incomplete', function () {
actingAsInstanceAdmin();
OauthSetting::where('provider', 'authentik')->first()->forceFill([
'enabled' => true,
'client_id' => 'authentik-client',
'client_secret' => 'authentik-secret',
'base_url' => 'https://authentik.example.com',
])->save();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.authentik.client_secret', '')
->call('submit')
->assertDispatched('error')
->assertSet('oauth_settings_map.authentik.enabled', false);
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
});
it('toggles provider enabled state from the action button', function () {
actingAsInstanceAdmin();
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
->set('oauth_settings_map.authentik.client_id', 'authentik-client')
->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
->call('toggleProvider', 'authentik')
->assertHasNoErrors();
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue();
});

View file

@ -153,7 +153,7 @@ function makeMuxServer(): Server
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->toContain("'bash -se' << \\")
->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\")
->not->toContain('<< $delimiter');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));

View file

@ -0,0 +1,16 @@
<?php
use App\Models\User;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('leaves the user id sequence ready for new development users', function () {
$this->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);
});

View file

@ -0,0 +1,62 @@
<?php
use App\Actions\Server\CheckUpdates;
use App\Actions\Server\InstallDocker;
use App\Actions\Server\InstallPrerequisites;
it('installs Bash while bootstrapping Alpine prerequisites', function () {
$method = new ReflectionMethod(InstallPrerequisites::class, 'getAlpinePrerequisiteCommands');
$commands = $method->invoke(new InstallPrerequisites);
expect($commands)->toContain('command -v bash >/dev/null || apk add bash');
});
it('installs every Docker CLI plugin required on Alpine', function () {
$method = new ReflectionMethod(InstallDocker::class, 'getAlpineDockerInstallCommand');
$command = $method->invoke(new InstallDocker);
expect($command)->toContain('apk add docker docker-cli-buildx docker-cli-compose');
});
it('uses OpenRC instead of systemd to restart Docker on Alpine', function () {
$method = new ReflectionMethod(InstallDocker::class, 'getDockerServiceCommands');
$action = new InstallDocker;
$commands = $method->invoke($action, true);
expect($commands)
->toBe(['rc-update add docker default', 'rc-service docker restart'])
->each->not->toContain('systemctl')
->and($method->invoke($action, false))
->toBe(['systemctl enable docker >/dev/null 2>&1 || true', 'systemctl restart docker']);
});
it('parses Alpine package updates', function () {
$method = new ReflectionMethod(CheckUpdates::class, 'parseApkOutput');
$output = <<<'OUTPUT'
docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
libcrypto3-3.3.4-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.3-r0]
OUTPUT;
$result = $method->invoke(new CheckUpdates, $output);
expect($result)->toBe([
'total_updates' => 2,
'updates' => [
[
'package' => 'docker-cli-compose',
'new_version' => '2.31.0-r5',
'architecture' => 'x86_64',
'current_version' => '2.31.0-r4',
],
[
'package' => 'libcrypto3',
'new_version' => '3.3.4-r0',
'architecture' => 'aarch64',
'current_version' => '3.3.3-r0',
],
],
]);
});

View file

@ -296,7 +296,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse();
});
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',
@ -315,13 +315,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);
@ -342,6 +342,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');
});

View file

@ -0,0 +1,30 @@
<?php
use App\Models\OauthSetting;
use Tests\TestCase;
uses(TestCase::class);
it('requires issuer url client id and client secret for oidc settings', function () {
$setting = new OauthSetting(['provider' => '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');
});

View file

@ -0,0 +1,119 @@
<?php
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
use App\Auth\Oidc\Exceptions\OidcJwksException;
use App\Auth\Oidc\OidcDiscoveryService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('fetches and caches discovery documents and jwks', function () {
Cache::flush();
Http::fake([
'https://idp.example.com/.well-known/openid-configuration' => Http::response([
'issuer' => 'https://idp.example.com',
'authorization_endpoint' => 'https://idp.example.com/auth',
'token_endpoint' => 'https://idp.example.com/token',
'userinfo_endpoint' => 'https://idp.example.com/userinfo',
'jwks_uri' => 'https://idp.example.com/jwks',
]),
'https://idp.example.com/jwks' => Http::response(['keys' => [['kid' => 'one']]]),
]);
$service = app(OidcDiscoveryService::class);
$discovery = $service->discover('https://idp.example.com');
$jwks = $service->jwks($discovery->jwksUri);
expect($discovery->issuer)->toBe('https://idp.example.com')
->and($jwks['keys'][0]['kid'])->toBe('one');
Http::assertSentCount(2);
$service->discover('https://idp.example.com');
$service->jwks('https://idp.example.com/jwks');
Http::assertSentCount(2);
});
it('does not cache discovery documents with mismatched issuers', function () {
Cache::flush();
Http::fakeSequence('https://idp.example.com/.well-known/openid-configuration')
->push([
'issuer' => 'https://evil.example.com',
'authorization_endpoint' => 'https://idp.example.com/auth',
'token_endpoint' => 'https://idp.example.com/token',
'userinfo_endpoint' => 'https://idp.example.com/userinfo',
'jwks_uri' => 'https://idp.example.com/jwks',
])
->push([
'issuer' => 'https://idp.example.com',
'authorization_endpoint' => 'https://idp.example.com/auth',
'token_endpoint' => 'https://idp.example.com/token',
'userinfo_endpoint' => 'https://idp.example.com/userinfo',
'jwks_uri' => 'https://idp.example.com/jwks',
]);
$service = app(OidcDiscoveryService::class);
$cacheKey = 'oidc:discovery:'.hash('sha256', 'https://idp.example.com');
expect(fn () => $service->discover('https://idp.example.com'))
->toThrow(OidcDiscoveryException::class, 'Discovery issuer does not match the configured issuer URL.')
->and(Cache::has($cacheKey))->toBeFalse()
->and($service->discover('https://idp.example.com')->issuer)->toBe('https://idp.example.com');
Http::assertSentCount(2);
});
it('refetches jwks once on forced refresh to pick up rotated keys', function () {
Cache::flush();
Http::fakeSequence('https://idp.example.com/jwks')
->push(['keys' => [['kid' => 'old']]])
->push(['keys' => [['kid' => 'new']]]);
$service = app(OidcDiscoveryService::class);
expect($service->jwks('https://idp.example.com/jwks')['keys'][0]['kid'])->toBe('old');
// Forced refresh bypasses the cache and sees the rotated key.
expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new');
Http::assertSentCount(2);
// Cooldown prevents a second immediate upstream fetch; cached value returned.
expect($service->jwks('https://idp.example.com/jwks', true)['keys'][0]['kid'])->toBe('new');
Http::assertSentCount(2);
});
it('rejects invalid discovery and jwks payloads', function () {
Cache::flush();
Http::fake([
'https://bad.example.com/.well-known/openid-configuration' => Http::response(['issuer' => 'https://bad.example.com']),
]);
app(OidcDiscoveryService::class)->discover('https://bad.example.com');
})->throws(OidcDiscoveryException::class);
it('rejects jwks responses without keys', function () {
Cache::flush();
Http::fake([
'https://idp.example.com/jwks' => Http::response(['empty' => true]),
]);
app(OidcDiscoveryService::class)->jwks('https://idp.example.com/jwks');
})->throws(OidcJwksException::class);
it('rejects non-https issuer urls', function () {
Cache::flush();
Http::fake();
app(OidcDiscoveryService::class)->discover('http://idp.example.com');
})->throws(OidcDiscoveryException::class, 'Issuer URL must be an absolute HTTPS URL.');
it('rejects non-https jwks uris', function () {
Cache::flush();
Http::fake();
app(OidcDiscoveryService::class)->jwks('http://idp.example.com/jwks');
})->throws(OidcJwksException::class, 'JWKS URI must be an absolute HTTPS URL.');

View file

@ -0,0 +1,148 @@
<?php
use App\Auth\Oidc\Exceptions\OidcException;
use App\Auth\Oidc\OidcConfig;
use App\Auth\Oidc\OidcDiscoveryDocument;
use App\Auth\Oidc\OidcDiscoveryService;
use App\Auth\Oidc\OidcTokenValidator;
use App\Auth\Oidc\Socialite\OidcProvider;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Illuminate\Session\ArraySessionHandler;
use Illuminate\Session\Store;
use Illuminate\Support\Carbon;
use Mockery\MockInterface;
use Tests\TestCase;
uses(TestCase::class);
class TestOidcProviderWithExposedAuthUrl extends OidcProvider
{
public function authUrlForState(string $state): string
{
return $this->getAuthUrl($state);
}
}
function oidc_provider_discovery_document(): OidcDiscoveryDocument
{
return new OidcDiscoveryDocument(
issuer: 'https://idp.example.com',
authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
tokenEndpoint: 'https://idp.example.com/oauth2/token',
userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo',
jwksUri: 'https://idp.example.com/.well-known/jwks.json',
);
}
function oidc_provider_session(): Store
{
$session = new Store('testing', new ArraySessionHandler(1200));
$session->start();
return $session;
}
function oidc_provider_request(Store $session, string $state = 'state-value'): Request
{
$request = Request::create('/auth/oidc/callback', 'GET', ['state' => $state]);
$request->setLaravelSession($session);
return $request;
}
function oidc_provider(Request $request): TestOidcProviderWithExposedAuthUrl
{
/** @var OidcDiscoveryService&MockInterface $discoveryService */
$discoveryService = Mockery::mock(OidcDiscoveryService::class);
$discoveryService->shouldReceive('discover')
->byDefault()
->with('https://idp.example.com')
->andReturn(oidc_provider_discovery_document());
/** @var OidcTokenValidator&MockInterface $tokenValidator */
$tokenValidator = Mockery::mock(OidcTokenValidator::class);
return (new TestOidcProviderWithExposedAuthUrl(
$request,
$discoveryService,
$tokenValidator,
'client-id',
'client-secret',
'https://coolify.example.com/auth/oidc/callback',
))->setConfig(new OidcConfig(
issuerUrl: 'https://idp.example.com',
clientId: 'client-id',
clientSecret: 'client-secret',
redirectUri: 'https://coolify.example.com/auth/oidc/callback',
usePkce: true,
));
}
it('stores oidc nonce and pkce verifier with a ten minute expiry', function () {
Carbon::setTestNow('2026-06-15 12:00:00');
try {
$session = oidc_provider_session();
$provider = oidc_provider(oidc_provider_request($session));
$provider->authUrlForState('state-value');
$nonceEntry = $session->get('oidc.nonce.state-value');
$verifierEntry = $session->get('oidc.code_verifier.state-value');
expect($nonceEntry)->toBeArray()
->and($nonceEntry['value'])->toBeString()->not->toBeEmpty()
->and($nonceEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp)
->and($verifierEntry)->toBeArray()
->and($verifierEntry['value'])->toBeString()->not->toBeEmpty()
->and($verifierEntry['expires_at'])->toBe(now()->addMinutes(10)->timestamp);
} finally {
Carbon::setTestNow();
}
});
it('sends a fresh oidc pkce verifier during token exchange', function () {
$session = oidc_provider_session();
$session->put('oidc.code_verifier.state-value', [
'value' => 'fresh-verifier',
'expires_at' => now()->addMinute()->timestamp,
]);
$provider = oidc_provider(oidc_provider_request($session));
$history = [];
$handler = HandlerStack::create(new MockHandler([
new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)),
]));
$handler->push(Middleware::history($history));
$provider->setHttpClient(new Client(['handler' => $handler]));
$provider->getAccessTokenResponse('authorization-code');
parse_str((string) $history[0]['request']->getBody(), $tokenRequestFields);
expect($tokenRequestFields['code_verifier'] ?? null)->toBe('fresh-verifier')
->and($session->has('oidc.code_verifier.state-value'))->toBeFalse();
});
it('throws a session expired error for an expired oidc pkce verifier during token exchange', function () {
$session = oidc_provider_session();
$session->put('oidc.code_verifier.state-value', [
'value' => 'expired-verifier',
'expires_at' => now()->subSecond()->timestamp,
]);
$provider = oidc_provider(oidc_provider_request($session));
$history = [];
$handler = HandlerStack::create(new MockHandler([
new Response(200, [], json_encode(['access_token' => 'access-token', 'id_token' => 'id-token'], JSON_THROW_ON_ERROR)),
]));
$handler->push(Middleware::history($history));
$provider->setHttpClient(new Client(['handler' => $handler]));
$provider->getAccessTokenResponse('authorization-code');
})->throws(OidcException::class, 'OIDC login session expired. Please try again.');

View file

@ -0,0 +1,187 @@
<?php
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
use App\Auth\Oidc\Exceptions\OidcTokenException;
use App\Auth\Oidc\OidcDiscoveryDocument;
use App\Auth\Oidc\OidcTokenValidator;
use Tests\TestCase;
uses(TestCase::class);
function oidc_base64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
function oidc_keyset(string $kid = 'test-key'): array
{
$privateKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($privateKey, $privatePem);
$details = openssl_pkey_get_details($privateKey);
return [
'private_pem' => $privatePem,
'jwks' => [
'keys' => [[
'kty' => 'RSA',
'kid' => $kid,
'alg' => 'RS256',
'use' => 'sig',
'n' => oidc_base64url($details['rsa']['n']),
'e' => oidc_base64url($details['rsa']['e']),
]],
],
];
}
function oidc_token(array $claims, string $privatePem, string $kid = 'test-key', string $algorithm = 'RS256'): string
{
$header = oidc_base64url(json_encode(['alg' => $algorithm, 'typ' => 'JWT', 'kid' => $kid], JSON_THROW_ON_ERROR));
$payload = oidc_base64url(json_encode($claims, JSON_THROW_ON_ERROR));
$signatureInput = $header.'.'.$payload;
openssl_sign($signatureInput, $signature, $privatePem, OPENSSL_ALGO_SHA256);
return $signatureInput.'.'.oidc_base64url($signature);
}
function oidc_discovery(): OidcDiscoveryDocument
{
return new OidcDiscoveryDocument(
issuer: 'https://idp.example.com',
authorizationEndpoint: 'https://idp.example.com/oauth2/authorize',
tokenEndpoint: 'https://idp.example.com/oauth2/token',
userinfoEndpoint: 'https://idp.example.com/oauth2/userinfo',
jwksUri: 'https://idp.example.com/.well-known/jwks.json',
);
}
it('validates a well formed RS256 id token', function () {
$keyset = oidc_keyset();
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
'nonce' => 'expected-nonce',
'email' => 'User@Example.com',
], $keyset['private_pem']);
$claims = app(OidcTokenValidator::class)->validate(
idToken: $token,
discovery: oidc_discovery(),
jwks: $keyset['jwks'],
clientId: 'client-id',
expectedNonce: 'expected-nonce',
);
expect($claims['sub'])->toBe('okta-user-1')
->and($claims['email'])->toBe('User@Example.com');
});
it('rejects invalid token claims', function (array $claimOverrides, string $message) {
$keyset = oidc_keyset();
$now = time();
$claims = array_merge([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
'nonce' => 'expected-nonce',
], $claimOverrides);
$token = oidc_token($claims, $keyset['private_pem']);
app(OidcTokenValidator::class)->validate(
idToken: $token,
discovery: oidc_discovery(),
jwks: $keyset['jwks'],
clientId: 'client-id',
expectedNonce: 'expected-nonce',
);
})->throws(OidcTokenException::class)->with([
'issuer mismatch' => [['iss' => 'https://evil.example.com'], 'issuer'],
'audience mismatch' => [['aud' => 'other-client'], 'audience'],
'azp missing for multi audience' => [['aud' => ['client-id', 'other-client']], 'azp'],
'azp mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'],
'expired token' => [['exp' => time() - 3600], 'expired'],
'future issued at' => [['iat' => time() + 3600], 'issued'],
'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'],
'missing subject' => [['sub' => null], 'subject'],
'empty subject' => [['sub' => ''], 'subject'],
'non-string subject' => [['sub' => 123], 'subject'],
]);
it('rejects a bad signature and unknown key id', function (string $kid) {
$keyset = oidc_keyset('test-key');
$otherKeyset = oidc_keyset($kid);
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
'nonce' => 'expected-nonce',
], $otherKeyset['private_pem'], $kid);
app(OidcTokenValidator::class)->validate(
idToken: $token,
discovery: oidc_discovery(),
jwks: $keyset['jwks'],
clientId: 'client-id',
expectedNonce: 'expected-nonce',
);
})->throws(OidcTokenException::class)->with([
'same kid with bad signature' => ['test-key'],
'unknown kid' => ['other-key'],
]);
it('rejects disallowed algorithms', function () {
$keyset = oidc_keyset();
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
], $keyset['private_pem'], algorithm: 'HS256');
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
})->throws(OidcTokenException::class);
it('throws a dedicated exception when the signing key is unknown', function () {
$keyset = oidc_keyset('current-key');
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => time(),
'exp' => time() + 600,
], $keyset['private_pem'], 'rotated-key');
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
})->throws(OidcSigningKeyNotFoundException::class);
it('rejects a jwks key not designated for signing', function () {
$keyset = oidc_keyset();
$keyset['jwks']['keys'][0]['use'] = 'enc';
$now = time();
$token = oidc_token([
'iss' => 'https://idp.example.com',
'aud' => 'client-id',
'sub' => 'okta-user-1',
'iat' => $now,
'exp' => $now + 600,
], $keyset['private_pem']);
// An encryption-only key is dropped from the keyset, so the kid no longer resolves.
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
})->throws(OidcTokenException::class);

View file

@ -23,6 +23,16 @@ public function test_generate_ssh_command_method_exists()
);
}
public function test_remote_shell_prefers_bash_and_falls_back_to_sh()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand');
$this->assertSame(
'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi',
$reflection->invoke(null)
);
}
public function test_generate_ssh_command_accepts_disable_multiplexing_parameter()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');

View file

@ -4,6 +4,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@ -18,7 +19,7 @@
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
Queue::fake();
$this->user = User::factory()->create([
@ -70,6 +71,21 @@
expect(Application::find($this->application->id))->toBeNull();
});
test('delete succeeds without password for an oauth user', function () {
OauthIdentity::create([
'user_id' => $this->user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'oauth-user-id',
]);
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', '')
->assertHasNoErrors();
expect(Application::find($this->application->id))->toBeNull();
});
test('delete applies selectedActions from checkbox state', function () {
$component = Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']);