mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
+6
@@ -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');
|
||||
}
|
||||
};
|
||||
+28
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+28
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,7 @@ class OauthSettingSeeder extends Seeder
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'oidc',
|
||||
'authentik',
|
||||
'infomaniak',
|
||||
'zitadel',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 |
@@ -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&accountId=%2A&zoneId=all&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&accountId=%2A&zoneId=all&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="[
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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&accountId=%2A&zoneId=all&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();
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 '));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user