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
+1 -1
View File
@@ -32,7 +32,7 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
if (! $settings->isPasswordRegistrationAllowed()) {
abort(403);
}
+39 -1
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 @@ class CheckUpdates
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
case 'apk':
instant_remote_process(['apk update -q'], $server);
$output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
$out = $this->parseApkOutput($output);
$out['osId'] = $osId;
$out['package_manager'] = $packageManager;
return $out;
default:
return [
@@ -266,11 +276,39 @@ class CheckUpdates
// Include unparsed lines in the result for debugging if any exist
if (! empty($unparsedLines)) {
$result['unparsed_lines'] = $unparsedLines;
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
Log::debug('Pacman output contained unparsed lines', [
'unparsed_lines' => $unparsedLines,
]);
}
return $result;
}
private function parseApkOutput(string $output): array
{
$updates = [];
$lines = explode("\n", $output);
foreach ($lines as $line) {
// Skip empty lines
if (empty($line)) {
continue;
}
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
$updates[] = [
'package' => $matches[1],
'new_version' => $matches[2],
'architecture' => $matches[3],
'current_version' => $matches[4],
];
}
}
return [
'total_updates' => count($updates),
'updates' => $updates,
];
}
}
+25 -2
View File
@@ -79,6 +79,8 @@ class InstallDocker
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
} elseif ($supported_os_type->contains('arch')) {
$command = $command->merge([$this->getArchDockerInstallCommand()]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge([$this->getAlpineDockerInstallCommand()]);
} else {
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
}
@@ -93,9 +95,8 @@ class InstallDocker
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
"echo 'Restarting Docker Engine...'",
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
]);
$command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
if ($server->isSwarm()) {
$command = $command->merge([
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
@@ -154,6 +155,28 @@ class InstallDocker
'systemctl start docker.service';
}
private function getAlpineDockerInstallCommand(): string
{
return 'apk update && '.
'apk add docker docker-cli-buildx docker-cli-compose && '.
'mkdir -p /etc/docker';
}
private function getDockerServiceCommands(bool $usesOpenRc): array
{
if ($usesOpenRc) {
return [
'rc-update add docker default',
'rc-service docker restart',
];
}
return [
'systemctl enable docker >/dev/null 2>&1 || true',
'systemctl restart docker',
];
}
private function getGenericDockerInstallCommand(): string
{
return 'curl -fsSL https://get.docker.com | sh';
@@ -53,6 +53,8 @@ class InstallPrerequisites
"echo 'Installing Prerequisites for Arch Linux...'",
'pacman -Syu --noconfirm --needed curl wget git jq',
]);
} elseif ($supported_os_type->contains('alpine')) {
$command = $command->merge($this->getAlpinePrerequisiteCommands());
} else {
throw new \Exception('Unsupported OS type for prerequisites installation');
}
@@ -61,4 +63,18 @@ class InstallPrerequisites
return remote_process($command, $server);
}
private function getAlpinePrerequisiteCommands(): array
{
return [
"echo 'Installing Prerequisites for Alpine Linux...'",
"sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
'apk update',
'command -v bash >/dev/null || apk add bash',
'command -v curl >/dev/null || apk add curl',
'command -v wget >/dev/null || apk add wget',
'command -v git >/dev/null || apk add git',
'command -v jq >/dev/null || apk add jq',
];
}
}
+4
View File
@@ -58,6 +58,10 @@ class UpdatePackage
$commandAll = 'pacman -Syu --noconfirm';
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
break;
case 'apk':
$commandAll = 'apk update && apk upgrade';
$commandInstall = 'apk upgrade '.$sanitizedPackage;
break;
default:
return [
'error' => 'OS not supported',
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcDiscoveryException extends OidcException {}
@@ -0,0 +1,7 @@
<?php
namespace App\Auth\Oidc\Exceptions;
use RuntimeException;
class OidcException extends RuntimeException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcJwksException extends OidcException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcSigningKeyNotFoundException extends OidcTokenException {}
@@ -0,0 +1,5 @@
<?php
namespace App\Auth\Oidc\Exceptions;
class OidcTokenException extends OidcException {}
+34
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,
);
}
}
+61
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));
}
}
+97
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;
}
}
}
+199
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.');
}
}
}
+32
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;
}
}
+299
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}";
}
}
+7 -1
View File
@@ -243,12 +243,18 @@ class SshMultiplexingHelper
$delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
$remoteShellCommand = self::remoteShellCommand();
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
}
private static function remoteShellCommand(): string
{
return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
}
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
+37 -24
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;
}
}
+24
View File
@@ -166,6 +166,30 @@ class Discord extends Component
}
}
public function toggleDiscordEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->discordEnabled) {
$this->discordEnabled = false;
} else {
$this->validate([
'discordWebhookUrl' => 'required',
], [
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
]);
$this->discordEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
}
}
public function instantSave()
{
try {
+88 -33
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 @@ use Livewire\Component;
class Email extends Component
{
use AuthorizesRequests, TogglesNotificationEvents;
use AuthorizesRequests;
protected $listeners = ['refresh' => '$refresh'];
@@ -252,32 +251,59 @@ class Email extends Component
}
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->saveModel();
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->saveModel();
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
@@ -309,17 +335,7 @@ class Email extends Component
try {
$this->resetErrorBag();
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
@@ -336,6 +352,45 @@ class Email extends Component
}
}
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {
+28
View File
@@ -159,6 +159,34 @@ class Pushover extends Component
}
}
public function togglePushoverEnabled()
{
try {
$this->resetErrorBag();
if ($this->pushoverEnabled) {
$this->pushoverEnabled = false;
} else {
$this->validate([
'pushoverUserKey' => 'required',
'pushoverApiToken' => 'required',
], [
'pushoverUserKey.required' => 'Pushover User Key is required.',
'pushoverApiToken.required' => 'Pushover API Token is required.',
]);
$this->pushoverEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {
+26
View File
@@ -150,6 +150,32 @@ class Slack extends Component
}
}
public function toggleSlackEnabled()
{
try {
$this->resetErrorBag();
if ($this->slackEnabled) {
$this->slackEnabled = false;
} else {
$this->validate([
'slackWebhookUrl' => 'required',
], [
'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
]);
$this->slackEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {
+28
View File
@@ -252,6 +252,34 @@ class Telegram extends Component
}
}
public function toggleTelegramEnabled(): void
{
try {
$this->resetErrorBag();
if ($this->telegramEnabled) {
$this->telegramEnabled = false;
} else {
$this->validate([
'telegramToken' => 'required',
'telegramChatId' => 'required',
], [
'telegramToken.required' => 'Telegram Token is required.',
'telegramChatId.required' => 'Telegram Chat ID is required.',
]);
$this->telegramEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function saveModel()
{
$this->syncData(true);
+24
View File
@@ -144,6 +144,30 @@ class Webhook extends Component
}
}
public function toggleWebhookEnabled()
{
try {
$this->resetErrorBag();
if ($this->webhookEnabled) {
$this->webhookEnabled = false;
} else {
$this->validate([
'webhookUrl' => 'required',
], [
'webhookUrl.required' => 'Webhook URL is required.',
]);
$this->webhookEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function instantSave()
{
try {
+53 -6
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 @@ class Index extends Component
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
$this->uses_sso = $oauthIdentity !== null;
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
// Check if there's a pending email change
if (Auth::user()->hasEmailChangeRequest()) {
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
$this->new_email = Auth::user()->pending_email;
$this->show_verification = true;
}
@@ -101,6 +105,10 @@ class Index extends Component
public function requestEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// For self-hosted, check if email is enabled
if (! isCloud()) {
$settings = instanceSettings();
@@ -159,6 +167,10 @@ class Index extends Component
public function verifyEmailChange()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
$this->validate([
'email_verification_code' => ['required', 'string', 'size:6'],
]);
@@ -204,7 +216,6 @@ class Index extends Component
$this->show_verification = false;
$this->dispatch('success', 'Email address updated successfully.');
$this->dispatch('close-email-change-modal');
} else {
$this->dispatch('error', 'Failed to update email address.');
}
@@ -216,6 +227,10 @@ class Index extends Component
public function resendVerificationCode()
{
try {
if ($this->rejectSsoEmailChange()) {
return;
}
// Check if there's a pending request
if (! Auth::user()->hasEmailChangeRequest()) {
$this->dispatch('error', 'No pending email change request.');
@@ -269,6 +284,30 @@ class Index extends Component
$this->dispatch('success', 'Email change request cancelled.');
}
public function showEmailChangeForm()
{
if ($this->rejectSsoEmailChange()) {
return;
}
$this->show_email_change = true;
$this->new_email = '';
}
private function rejectSsoEmailChange(): bool
{
if (! Auth::user()->hasSsoIdentity()) {
return false;
}
$this->uses_sso = true;
$this->show_email_change = false;
$this->show_verification = false;
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
return true;
}
public function resetPassword()
{
try {
@@ -299,6 +338,14 @@ class Index extends Component
}
}
private function providerLabel(string $provider): string
{
return match ($provider) {
'oidc' => 'OIDC',
default => str($provider)->headline()->toString(),
};
}
public function render()
{
return view('livewire.profile.index');
@@ -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');
}
}
@@ -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');
}
}
@@ -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');
}
}
+72
View File
@@ -177,6 +177,49 @@ class LogDrains extends Component
}
}
public function toggleLogDrain(string $type): void
{
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
try {
$this->authorize('update', $this->server);
$this->resetErrorBag();
$enabledProperty = $this->enabledProperty($type);
if ($this->{$enabledProperty}) {
$this->{$enabledProperty} = false;
} else {
$this->validateLogDrainSettings($type);
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
$this->isLogDrainAxiomEnabled = $type === 'axiom';
$this->isLogDrainCustomEnabled = $type === 'custom';
}
$this->syncData(true);
if ($this->server->isLogDrainEnabled()) {
StartLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service started.');
} else {
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
$this->server->settings->save();
$this->syncData();
handleError($e, $this);
}
}
public function submit()
{
try {
@@ -192,4 +235,33 @@ class LogDrains extends Component
{
return view('livewire.server.log-drains');
}
private function enabledProperty(string $type): string
{
return match ($type) {
'newrelic' => 'isLogDrainNewRelicEnabled',
'axiom' => 'isLogDrainAxiomEnabled',
'custom' => 'isLogDrainCustomEnabled',
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
private function validateLogDrainSettings(string $type): void
{
match ($type) {
'newrelic' => $this->validate([
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainNewRelicBaseUri' => ['required', 'url'],
]),
'axiom' => $this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
]),
'custom' => $this->validate([
'logDrainCustomConfig' => ['required'],
'logDrainCustomConfigParser' => ['string', 'nullable'],
]),
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
}
+6
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 @@ class Advanced extends Component
{
return [
'is_registration_enabled' => 'boolean',
'disable_registration_when_oauth_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers],
@@ -84,6 +88,7 @@ class Advanced extends Component
$this->allowed_ips = $this->settings->allowed_ips;
$this->do_not_track = $this->settings->do_not_track;
$this->is_registration_enabled = $this->settings->is_registration_enabled;
$this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled;
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
@@ -199,6 +204,7 @@ class Advanced extends Component
try {
$this->authorize('update', $this->settings);
$this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->custom_dns_servers = $this->custom_dns_servers;
+93 -31
View File
@@ -160,30 +160,59 @@ class SettingsEmail extends Component
$this->instantSave('Resend');
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->syncData(true);
$this->dispatch('success', 'SMTP settings updated.');
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->syncData(true);
$this->dispatch('success', 'Resend settings updated.');
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function submitSmtp()
{
try {
$this->authorize('update', $this->settings);
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
$this->validateSmtpSettings();
if ($this->smtpEnabled) {
$this->settings->resend_enabled = $this->resendEnabled = false;
}
$this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_host = $this->smtpHost;
@@ -210,17 +239,11 @@ class SettingsEmail extends Component
{
try {
$this->authorize('update', $this->settings);
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
$this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey;
@@ -237,6 +260,45 @@ class SettingsEmail extends Component
}
}
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {
+232 -114
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 @@ class SettingsOauth extends Component
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
$oauth->fill([
'enabled' => $oauthData['enabled'],
'client_id' => $oauthData['client_id'],
'client_secret' => $oauthData['client_secret'],
'redirect_uri' => $oauthData['redirect_uri'],
'tenant' => $oauthData['tenant'],
'base_url' => $oauthData['base_url'],
]);
if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<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 @@ class SettingsOauth extends Component
}
}
public function toggleProvider(string $provider): mixed
public function toggleProvider(string $provider)
{
try {
$this->authorize('update', instanceSettings());
if (! array_key_exists($provider, $this->oauth_settings_map)) {
throw new \Exception('OAuth provider not found.');
abort(404);
}
$enabling = ! $this->oauth_settings_map[$provider]['enabled'];
if ($enabling) {
$this->validate($this->providerRules($provider));
if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
$this->validateProviderCanBeEnabled($provider);
}
$this->oauth_settings_map[$provider]['enabled'] = $enabling;
$this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
$this->updateOauthSettings($provider);
} catch (\Throwable $e) {
} catch (\Exception $e) {
$oauth = OauthSetting::where('provider', $provider)->first();
if ($oauth) {
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
}
return handleError($e, $this);
}
return null;
}
private function providerRules(string $provider): array
private function validateProviderCanBeEnabled(string $provider): void
{
$prefix = "oauth_settings_map.$provider";
$rules = [
"$prefix.client_id" => 'required',
"$prefix.client_secret" => 'required',
];
$this->validate($this->validationRules($provider));
if ($provider === 'azure') {
$rules["$prefix.tenant"] = 'required';
$oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
if (! $oauth) {
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
if (in_array($provider, ['authentik', 'clerk'], true)) {
$rules["$prefix.base_url"] = 'required';
$this->fillOauthSetting($oauth, [
...$this->oauth_settings_map[$provider],
'enabled' => true,
]);
if (! $oauth->couldBeEnabled()) {
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<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);
}
}
}
+16
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 @@ class InstanceSettings extends Model
});
}
public function isPasswordRegistrationAllowed(): bool
{
if (! $this->is_registration_enabled) {
return false;
}
if (! $this->disable_registration_when_oauth_enabled) {
return true;
}
return ! OauthSetting::where('enabled', true)->exists();
}
public function fqdn(): Attribute
{
return Attribute::make(
+38
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);
}
}
+35
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);
}
}
+50 -1
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 @@ class OauthSetting extends Model
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
case 'authentik':
case 'clerk':
case 'oidc':
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
default:
return filled($this->client_id) && filled($this->client_secret);
}
}
/**
* @return array<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';
}
}
+5
View File
@@ -304,6 +304,11 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
return $this->hasMany(CloudProviderToken::class);
}
public function integrationTokens()
{
return $this->hasMany(IntegrationToken::class);
}
public function sources()
{
$sources = collect([]);
+16 -1
View File
@@ -11,6 +11,7 @@ use App\Services\ChangelogService;
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notifiable;
@@ -507,12 +508,26 @@ class User extends Authenticatable implements SendsEmail
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
}
public function oauthIdentities(): HasMany
{
return $this->hasMany(OauthIdentity::class);
}
public function hasSsoIdentity(): bool
{
return $this->oauthIdentities()->exists();
}
/**
* Check if the user has a password set.
* OAuth users are created without passwords.
*/
public function hasPassword(): bool
{
return ! empty($this->password);
}
public function requiresPasswordConfirmation(): bool
{
return $this->hasPassword() && ! $this->hasSsoIdentity();
}
}
+34
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;
}
}
+23 -13
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\Facades\Http;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
@@ -22,12 +26,11 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureCommands();
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
$this->configureGitHubHttp();
$this->configureOidcSocialite();
}
private function configureCommands(): void
@@ -62,6 +65,24 @@ class AppServiceProvider extends ServiceProvider
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}
private function configureOidcSocialite(): void
{
if (! $this->app->bound(SocialiteFactory::class)) {
return;
}
$this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) {
return new OidcProvider(
$app['request'],
$app->make(OidcDiscoveryService::class),
$app->make(OidcTokenValidator::class),
'',
'',
'',
);
});
}
private function configureGitHubHttp(): void
{
Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) {
@@ -77,16 +98,5 @@ class AppServiceProvider extends ServiceProvider
])->baseUrl($api_url);
}
});
Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
$client = Http::withHeaders([
'Accept' => 'application/json',
])->baseUrl($api_url);
if ($access_token) {
$client = $client->withToken($access_token);
}
return $client;
});
}
}
+3
View File
@@ -15,6 +15,7 @@ use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\PushoverNotificationSettings;
@@ -52,6 +53,7 @@ use App\Policies\EnvironmentVariablePolicy;
use App\Policies\GithubAppPolicy;
use App\Policies\GitlabAppPolicy;
use App\Policies\InstanceSettingsPolicy;
use App\Policies\IntegrationTokenPolicy;
use App\Policies\NotificationPolicy;
use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy;
@@ -132,6 +134,7 @@ class AuthServiceProvider extends ServiceProvider
// Cloud provider policies
CloudProviderToken::class => CloudProviderTokenPolicy::class,
IntegrationToken::class => IntegrationTokenPolicy::class,
CloudInitScript::class => CloudInitScriptPolicy::class,
Tag::class => TagPolicy::class,
+4 -4
View File
@@ -48,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
if (! $settings->isPasswordRegistrationAllowed()) {
return redirect()->route('login');
}
@@ -61,13 +61,13 @@ class FortifyServiceProvider extends ServiceProvider
$settings = instanceSettings();
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// If there are no users, redirect to registration
if ($users == 0 && $settings->isPasswordRegistrationAllowed()) {
// If there are no users and password registration is allowed, redirect to registration.
return redirect()->route('register');
}
return view('auth.login', [
'is_registration_enabled' => $settings->is_registration_enabled,
'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(),
'enabled_oauth_providers' => $enabled_oauth_providers,
]);
});
+228
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;
});
}
}
+42
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);
}
}
+5 -4
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
+19 -9
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(
+1
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",
Generated
+1 -1
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",
+8
View File
@@ -60,6 +60,14 @@ return [
'tenant' => env('GOOGLE_TENANT'),
],
'oidc' => [
'client_id' => env('OIDC_CLIENT_ID'),
'client_secret' => env('OIDC_CLIENT_SECRET'),
'redirect' => env('OIDC_REDIRECT_URI'),
'base_url' => env('OIDC_BASE_URL'),
'custom_label' => env('OIDC_LOGIN_LABEL'),
],
'zitadel' => [
'client_id' => env('ZITADEL_CLIENT_ID'),
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
@@ -8,6 +8,12 @@ return new class extends Migration
/**
* The configuration snapshot/diff now store an encrypted blob (not valid
* JSON), so the columns must hold arbitrary text instead of json.
*
* Coolify's own backend runs exclusively on PostgreSQL in production and
* SQLite in testing (see config/database.php the only configured
* connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed
* resources, never Coolify's application database, so no driver path is
* needed for them here.
*/
public function up(): void
{
@@ -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',
]);
});
}
};
@@ -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');
}
};
@@ -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');
});
}
};
@@ -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');
});
}
};
@@ -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');
}
};
+1
View File
@@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder
'github',
'gitlab',
'google',
'oidc',
'authentik',
'infomaniak',
'zitadel',
-2
View File
@@ -15,12 +15,10 @@ class UserSeeder extends Seeder
'email' => 'test@example.com',
]);
User::factory()->create([
'id' => 1,
'name' => 'Normal User (but in root team)',
'email' => 'test2@example.com',
]);
User::factory()->create([
'id' => 2,
'name' => 'Normal User (not in root team)',
'email' => 'test3@example.com',
]);
+1
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?",
+1
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?",
+1
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
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

+6 -2
View File
@@ -80,11 +80,15 @@
@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>
@@ -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',
@@ -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',
@@ -134,15 +134,22 @@
<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 @@
@endif
</div>
</div>
</template>
</template>
@endif
<form wire:submit="resetPassword">
<section class="application-settings-section">
@@ -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>
@@ -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>
@@ -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>
@@ -35,8 +35,8 @@
</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>
@@ -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
@@ -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="[
+5
View File
@@ -47,6 +47,7 @@ use App\Livewire\Security\CloudInitScript\Show as SecurityCloudInitScriptShow;
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::middleware(['auth', 'verified'])->group(function () {
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
Route::get('/settings/oauth/{provider}', SettingsOauth::class)
->where('provider', '[A-Za-z0-9_-]+')
->name('settings.oauth.provider');
Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
Route::get('/profile', ProfileIndex::class)->name('profile');
@@ -385,6 +389,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
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');
+2 -2
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": {
+2 -2
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": {
+179
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);
});
@@ -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();
});
+22
View File
@@ -37,6 +37,28 @@ test('auth pages use the Coollabs purple background glow', function () {
->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'));
+113 -1
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 @@ it('logs in an existing user when the oauth provider returns a mixed-case email'
$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 @@ it('rejects oauth logins when the provider does not return an email address', fu
})->with([
'null email' => [null],
'blank email' => [' '],
'malformed email' => ['not-an-email'],
'missing domain' => ['user@'],
]);
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],
]);
@@ -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');
});
+275
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;
});
});
+91
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();
});
@@ -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');
});
@@ -8,6 +8,7 @@ it('uses shared sidebar navigation for keys and tokens pages', function () {
'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 @@ it('uses shared sidebar navigation for keys and tokens pages', function () {
->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'");
@@ -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();
});
+52
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>');
});
+277
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();
});
+1 -1
View File
@@ -153,7 +153,7 @@ it('adds mux options to ssh commands only after the explicit master is ready', f
->toContain('-o ControlMaster=auto')
->toContain("-o ControlPath=/var/www/html/storage/app/ssh/mux/mux_{$server->uuid}")
->toContain('-o ControlPersist=3600')
->toContain("'bash -se' << \\")
->toContain("'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\")
->not->toContain('<< $delimiter');
Process::assertRan(fn ($process) => str_contains($process->command, 'ssh -fN '));
+16
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);
});
@@ -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',
],
],
]);
});
@@ -296,7 +296,7 @@ it('accepts the historical environment sorting default in older snapshots', func
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 @@ it('detects environment variable value changes without exposing secret values',
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('old-secret')
->and($change['new_display_value'])->toBe('new-secret')
->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret');
});
it('describes added environment variables as set without exposing secret values', function () {
it('describes added unlocked environment variables with their value', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
@@ -342,6 +342,6 @@ it('describes added environment variables as set without exposing secret values'
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
->and($change['new_display_value'])->toBe('new-secret')
->and(json_encode($diff->toArray()))->toContain('new-secret');
});
+30
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');
});
+119
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.');
+148
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.');
+187
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);
+10
View File
@@ -23,6 +23,16 @@ class SshMultiplexingDisableTest extends TestCase
);
}
public function test_remote_shell_prefers_bash_and_falls_back_to_sh()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'remoteShellCommand');
$this->assertSame(
'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi',
$reflection->invoke(null)
);
}
public function test_generate_ssh_command_accepts_disable_multiplexing_parameter()
{
$reflection = new \ReflectionMethod(SshMultiplexingHelper::class, 'generateSshCommand');
+17 -1
View File
@@ -4,6 +4,7 @@ use App\Livewire\Project\Shared\Danger;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -18,7 +19,7 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
Queue::fake();
$this->user = User::factory()->create([
@@ -70,6 +71,21 @@ test('delete succeeds with correct password and redirects', function () {
expect(Application::find($this->application->id))->toBeNull();
});
test('delete succeeds without password for an oauth user', function () {
OauthIdentity::create([
'user_id' => $this->user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'oauth-user-id',
]);
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', '')
->assertHasNoErrors();
expect(Application::find($this->application->id))->toBeNull();
});
test('delete applies selectedActions from checkbox state', function () {
$component = Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', 'test-password', ['delete_configurations', 'docker_cleanup']);