mirror of
https://github.com/coollabsio/coolify.git
synced 2026-06-19 07:35:25 +00:00
feat(auth): add OIDC SSO and registration controls
Add OIDC discovery, JWKS validation, Socialite integration, OAuth identity linking, and configurable registration policy for password and SSO signups. Update related settings/profile UI and tests.
This commit is contained in:
@@ -20,7 +20,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
public function create(array $input): User
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
if (! $settings->isPasswordRegistrationAllowed()) {
|
||||
abort(403);
|
||||
}
|
||||
Validator::make($input, [
|
||||
|
||||
@@ -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 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,73 @@
|
||||
<?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
|
||||
{
|
||||
$issuerUrl = rtrim($issuerUrl, '/');
|
||||
$cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl);
|
||||
|
||||
$payload = Cache::remember($cacheKey, 3600, function () use ($issuerUrl): array {
|
||||
$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.');
|
||||
}
|
||||
|
||||
return $json;
|
||||
});
|
||||
|
||||
$discovery = OidcDiscoveryDocument::fromArray($payload);
|
||||
if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
|
||||
throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
|
||||
}
|
||||
|
||||
return $discovery;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function jwks(string $jwksUri): array
|
||||
{
|
||||
$cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
|
||||
|
||||
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
|
||||
try {
|
||||
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
|
||||
throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
|
||||
}
|
||||
|
||||
return $json;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcTokenException;
|
||||
|
||||
class OidcTokenValidator
|
||||
{
|
||||
/**
|
||||
* @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 {
|
||||
[$header, $claims, $signatureInput, $signature] = $this->parse($idToken);
|
||||
|
||||
$algorithm = $header['alg'] ?? null;
|
||||
if ($algorithm !== 'RS256') {
|
||||
throw new OidcTokenException('id_token uses a disallowed algorithm.');
|
||||
}
|
||||
|
||||
$kid = $header['kid'] ?? null;
|
||||
if (! is_string($kid) || $kid === '') {
|
||||
throw new OidcTokenException('id_token header is missing kid.');
|
||||
}
|
||||
|
||||
$jwk = $this->findJwk($jwks, $kid);
|
||||
$publicKey = RsaJwk::toPem($jwk);
|
||||
|
||||
if (openssl_verify($signatureInput, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
|
||||
throw new OidcTokenException('id_token signature is invalid.');
|
||||
}
|
||||
|
||||
$this->assertIssuer($claims, $discovery->issuer);
|
||||
$this->assertAudience($claims, $clientId);
|
||||
$this->assertTimestamps($claims, $clockSkewSeconds);
|
||||
$this->assertNonce($claims, $expectedNonce);
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<string, mixed>, 1: array<string, mixed>, 2: string, 3: string}
|
||||
*/
|
||||
private function parse(string $idToken): array
|
||||
{
|
||||
$segments = explode('.', $idToken);
|
||||
if (count($segments) !== 3) {
|
||||
throw new OidcTokenException('Malformed id_token.');
|
||||
}
|
||||
|
||||
$header = json_decode(RsaJwk::base64UrlDecode($segments[0]), true);
|
||||
$claims = json_decode(RsaJwk::base64UrlDecode($segments[1]), true);
|
||||
|
||||
if (! is_array($header) || ! is_array($claims)) {
|
||||
throw new OidcTokenException('id_token contains invalid JSON.');
|
||||
}
|
||||
|
||||
return [$header, $claims, $segments[0].'.'.$segments[1], RsaJwk::base64UrlDecode($segments[2])];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $jwks
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function findJwk(array $jwks, string $kid): array
|
||||
{
|
||||
foreach ($jwks['keys'] ?? [] as $jwk) {
|
||||
if (is_array($jwk) && ($jwk['kid'] ?? null) === $kid) {
|
||||
return $jwk;
|
||||
}
|
||||
}
|
||||
|
||||
throw new OidcTokenException('No matching JWKS key found for id_token kid.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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 (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 assertTimestamps(array $claims, int $clockSkewSeconds): void
|
||||
{
|
||||
$now = time();
|
||||
$expiration = $claims['exp'] ?? null;
|
||||
if (! is_int($expiration) || ($expiration + $clockSkewSeconds) < $now) {
|
||||
throw new OidcTokenException('id_token is expired.');
|
||||
}
|
||||
|
||||
$issuedAt = $claims['iat'] ?? null;
|
||||
if (! is_int($issuedAt) || ($issuedAt - $clockSkewSeconds) > $now) {
|
||||
throw new OidcTokenException('id_token issued at time is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcTokenException;
|
||||
|
||||
class RsaJwk
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $jwk
|
||||
*/
|
||||
public static function toPem(array $jwk): string
|
||||
{
|
||||
if (($jwk['kty'] ?? null) !== 'RSA' || ! is_string($jwk['n'] ?? null) || ! is_string($jwk['e'] ?? null)) {
|
||||
throw new OidcTokenException('JWKS key is not a valid RSA signing key.');
|
||||
}
|
||||
|
||||
$modulus = self::base64UrlDecode($jwk['n']);
|
||||
$exponent = self::base64UrlDecode($jwk['e']);
|
||||
|
||||
$sequence = self::encodeSequence(
|
||||
self::encodeInteger($modulus).
|
||||
self::encodeInteger($exponent)
|
||||
);
|
||||
|
||||
$bitString = self::encodeBitString($sequence);
|
||||
$algorithmIdentifier = self::encodeSequence(
|
||||
self::encodeObjectIdentifier('1.2.840.113549.1.1.1').
|
||||
self::encodeNull()
|
||||
);
|
||||
|
||||
$subjectPublicKeyInfo = self::encodeSequence($algorithmIdentifier.$bitString);
|
||||
|
||||
return "-----BEGIN PUBLIC KEY-----\n".
|
||||
chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n").
|
||||
"-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
|
||||
public static function base64UrlDecode(string $value): string
|
||||
{
|
||||
$remainder = strlen($value) % 4;
|
||||
if ($remainder !== 0) {
|
||||
$value .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
|
||||
if ($decoded === false) {
|
||||
throw new OidcTokenException('Invalid base64url value.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private static function encodeLength(int $length): string
|
||||
{
|
||||
if ($length < 128) {
|
||||
return chr($length);
|
||||
}
|
||||
|
||||
$encoded = ltrim(pack('N', $length), "\x00");
|
||||
|
||||
return chr(0x80 | strlen($encoded)).$encoded;
|
||||
}
|
||||
|
||||
private static function encodeInteger(string $value): string
|
||||
{
|
||||
$value = ltrim($value, "\x00");
|
||||
if ($value === '') {
|
||||
$value = "\x00";
|
||||
}
|
||||
if ((ord($value[0]) & 0x80) !== 0) {
|
||||
$value = "\x00".$value;
|
||||
}
|
||||
|
||||
return "\x02".self::encodeLength(strlen($value)).$value;
|
||||
}
|
||||
|
||||
private static function encodeSequence(string $value): string
|
||||
{
|
||||
return "\x30".self::encodeLength(strlen($value)).$value;
|
||||
}
|
||||
|
||||
private static function encodeBitString(string $value): string
|
||||
{
|
||||
$value = "\x00".$value;
|
||||
|
||||
return "\x03".self::encodeLength(strlen($value)).$value;
|
||||
}
|
||||
|
||||
private static function encodeNull(): string
|
||||
{
|
||||
return "\x05\x00";
|
||||
}
|
||||
|
||||
private static function encodeObjectIdentifier(string $oid): string
|
||||
{
|
||||
$parts = array_map('intval', explode('.', $oid));
|
||||
$encoded = chr((40 * $parts[0]) + $parts[1]);
|
||||
|
||||
foreach (array_slice($parts, 2) as $part) {
|
||||
$stack = [chr($part & 0x7F)];
|
||||
$part >>= 7;
|
||||
while ($part > 0) {
|
||||
array_unshift($stack, chr(($part & 0x7F) | 0x80));
|
||||
$part >>= 7;
|
||||
}
|
||||
$encoded .= implode('', $stack);
|
||||
}
|
||||
|
||||
return "\x06".self::encodeLength(strlen($encoded)).$encoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Socialite;
|
||||
|
||||
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\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
|
||||
{
|
||||
/**
|
||||
* @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->request->session()->put($this->nonceSessionKey(), $nonce);
|
||||
|
||||
$extra = ['nonce' => $nonce];
|
||||
if ($config->usePkce) {
|
||||
$verifier = $this->generateCodeVerifier();
|
||||
$this->request->session()->put($this->verifierSessionKey(), $verifier);
|
||||
$extra['code_challenge'] = $this->codeChallenge($verifier);
|
||||
$extra['code_challenge_method'] = 'S256';
|
||||
}
|
||||
|
||||
return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
|
||||
.'&'.http_build_query($extra, '', '&', $this->encodingType);
|
||||
}
|
||||
|
||||
protected function getTokenUrl(): string
|
||||
{
|
||||
return $this->resolveDiscovery()->tokenEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getUserByToken($token): array
|
||||
{
|
||||
$response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
|
||||
RequestOptions::HEADERS => [
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
],
|
||||
]);
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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();
|
||||
$claims = $this->tokenValidator->validate(
|
||||
idToken: $idToken,
|
||||
discovery: $discovery,
|
||||
jwks: $this->discoveryService->jwks($discovery->jwksUri),
|
||||
clientId: $config->clientId,
|
||||
expectedNonce: $this->request->session()->pull($this->nonceSessionKey()),
|
||||
clockSkewSeconds: $config->clockSkewSeconds,
|
||||
);
|
||||
|
||||
$userinfo = $this->getUserByToken($accessToken);
|
||||
$merged = array_merge($userinfo, $claims);
|
||||
|
||||
/** @var OidcUser $user */
|
||||
$user = $this->mapUserToObject($merged);
|
||||
$user->setIdTokenClaims($claims)
|
||||
->setToken($accessToken)
|
||||
->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
|
||||
->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
|
||||
|
||||
return $this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAccessTokenResponse($code)
|
||||
{
|
||||
$fields = $this->getTokenFields($code);
|
||||
if ($this->getConfig()->usePkce) {
|
||||
$verifier = $this->request->session()->pull($this->verifierSessionKey());
|
||||
if (is_string($verifier) && $verifier !== '') {
|
||||
$fields['code_verifier'] = $verifier;
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
|
||||
RequestOptions::HEADERS => ['Accept' => 'application/json'],
|
||||
RequestOptions::FORM_PARAMS => $fields,
|
||||
]);
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function resolveDiscovery(): OidcDiscoveryDocument
|
||||
{
|
||||
return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
|
||||
}
|
||||
|
||||
protected function generateCodeVerifier(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
protected function codeChallenge(string $verifier): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<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 nonceSessionKey(): string
|
||||
{
|
||||
return 'oidc.nonce';
|
||||
}
|
||||
|
||||
protected function verifierSessionKey(): string
|
||||
{
|
||||
return 'oidc.code_verifier';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,30 @@ class Discord extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleDiscordEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->discordEnabled) {
|
||||
$this->discordEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'discordWebhookUrl' => 'required',
|
||||
], [
|
||||
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
|
||||
]);
|
||||
$this->discordEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -240,29 +240,57 @@ 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()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
$this->validateSmtpSettings();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->settings->resend_enabled = $this->resendEnabled = false;
|
||||
@@ -291,17 +319,7 @@ class Email extends Component
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => 'required|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
$this->validateResendSettings();
|
||||
if ($this->resendEnabled) {
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled = false;
|
||||
}
|
||||
@@ -318,6 +336,44 @@ 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',
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateResendSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => 'required|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTestEmail()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -153,6 +153,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 {
|
||||
|
||||
@@ -147,6 +147,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 {
|
||||
|
||||
@@ -246,6 +246,34 @@ class Telegram extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleTelegramEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->telegramEnabled) {
|
||||
$this->telegramEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'telegramToken' => 'required',
|
||||
'telegramChatId' => 'required',
|
||||
], [
|
||||
'telegramToken.required' => 'Telegram Token is required.',
|
||||
'telegramChatId.required' => 'Telegram Chat ID is required.',
|
||||
]);
|
||||
$this->telegramEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->syncData(true);
|
||||
|
||||
@@ -141,6 +141,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 {
|
||||
|
||||
@@ -32,14 +32,22 @@ class Index extends Component
|
||||
|
||||
public bool $show_verification = false;
|
||||
|
||||
public bool $uses_sso = false;
|
||||
|
||||
public ?string $sso_provider_label = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->userId = Auth::id();
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
|
||||
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
|
||||
$this->uses_sso = $oauthIdentity !== null;
|
||||
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
|
||||
|
||||
// Check if there's a pending email change
|
||||
if (Auth::user()->hasEmailChangeRequest()) {
|
||||
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
|
||||
$this->new_email = Auth::user()->pending_email;
|
||||
$this->show_verification = true;
|
||||
}
|
||||
@@ -64,6 +72,10 @@ class Index extends Component
|
||||
public function requestEmailChange()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For self-hosted, check if email is enabled
|
||||
if (! isCloud()) {
|
||||
$settings = instanceSettings();
|
||||
@@ -122,6 +134,10 @@ class Index extends Component
|
||||
public function verifyEmailChange()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'email_verification_code' => ['required', 'string', 'size:6'],
|
||||
]);
|
||||
@@ -178,6 +194,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.');
|
||||
@@ -233,10 +253,28 @@ class Index extends Component
|
||||
|
||||
public function showEmailChangeForm()
|
||||
{
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->show_email_change = true;
|
||||
$this->new_email = '';
|
||||
}
|
||||
|
||||
private function rejectSsoEmailChange(): bool
|
||||
{
|
||||
if (! Auth::user()->hasSsoIdentity()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->uses_sso = true;
|
||||
$this->show_email_change = false;
|
||||
$this->show_verification = false;
|
||||
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resetPassword()
|
||||
{
|
||||
try {
|
||||
@@ -267,6 +305,14 @@ 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');
|
||||
|
||||
@@ -177,6 +177,39 @@ class LogDrains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleLogDrain(string $type)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->resetErrorBag();
|
||||
|
||||
$enabledProperty = $this->enabledProperty($type);
|
||||
|
||||
if ($this->{$enabledProperty}) {
|
||||
$this->{$enabledProperty} = false;
|
||||
} else {
|
||||
$this->validateLogDrainSettings($type);
|
||||
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
|
||||
$this->isLogDrainAxiomEnabled = $type === 'axiom';
|
||||
$this->isLogDrainCustomEnabled = $type === 'custom';
|
||||
}
|
||||
|
||||
$this->syncData(true);
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
StartLogDrain::run($this->server);
|
||||
$this->dispatch('success', 'Log drain service started.');
|
||||
} else {
|
||||
StopLogDrain::run($this->server);
|
||||
$this->dispatch('success', 'Log drain service stopped.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit(string $type)
|
||||
{
|
||||
try {
|
||||
@@ -192,4 +225,33 @@ 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.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ class Advanced extends Component
|
||||
#[Validate('boolean')]
|
||||
public bool $is_registration_enabled;
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $disable_registration_when_oauth_enabled;
|
||||
|
||||
#[Validate('boolean')]
|
||||
public bool $do_not_track;
|
||||
|
||||
@@ -44,6 +47,7 @@ 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],
|
||||
@@ -66,6 +70,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;
|
||||
@@ -147,6 +152,7 @@ class Advanced extends Component
|
||||
{
|
||||
try {
|
||||
$this->settings->is_registration_enabled = $this->is_registration_enabled;
|
||||
$this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
|
||||
$this->settings->do_not_track = $this->do_not_track;
|
||||
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
|
||||
$this->settings->custom_dns_servers = $this->custom_dns_servers;
|
||||
|
||||
@@ -138,28 +138,54 @@ class SettingsEmail extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleSmtp()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->smtpEnabled = false;
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'SMTP settings updated.');
|
||||
} else {
|
||||
$this->validateSmtpSettings();
|
||||
$this->smtpEnabled = true;
|
||||
$this->resendEnabled = false;
|
||||
$this->submitSmtp();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleResend()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->resendEnabled) {
|
||||
$this->resendEnabled = false;
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Resend settings updated.');
|
||||
} else {
|
||||
$this->validateResendSettings();
|
||||
$this->resendEnabled = true;
|
||||
$this->smtpEnabled = false;
|
||||
$this->submitResend();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitSmtp()
|
||||
{
|
||||
try {
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
$this->validateSmtpSettings();
|
||||
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled;
|
||||
$this->settings->smtp_host = $this->smtpHost;
|
||||
@@ -184,17 +210,7 @@ class SettingsEmail extends Component
|
||||
public function submitResend()
|
||||
{
|
||||
try {
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => 'required|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
$this->validateResendSettings();
|
||||
|
||||
$this->settings->resend_enabled = $this->resendEnabled;
|
||||
$this->settings->resend_api_key = $this->resendApiKey;
|
||||
@@ -211,6 +227,44 @@ 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',
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateResendSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => 'required|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTestEmail()
|
||||
{
|
||||
try {
|
||||
|
||||
+224
-89
@@ -9,43 +9,68 @@ class SettingsOauth extends Component
|
||||
{
|
||||
public $oauth_settings_map;
|
||||
|
||||
protected function rules()
|
||||
public ?string $selectedProvider = null;
|
||||
|
||||
public bool $disable_registration_when_oauth_enabled = false;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return OauthSetting::all()->reduce(function ($carry, $setting) {
|
||||
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required';
|
||||
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable';
|
||||
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable';
|
||||
return $this->validationRules();
|
||||
}
|
||||
|
||||
private function validationRules(?string $provider = null): array
|
||||
{
|
||||
$rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) {
|
||||
if ($provider !== null && $setting->provider !== $provider) {
|
||||
return $carry;
|
||||
}
|
||||
|
||||
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string';
|
||||
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string';
|
||||
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https';
|
||||
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
|
||||
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
|
||||
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
|
||||
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
|
||||
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
|
||||
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
|
||||
|
||||
return $carry;
|
||||
}, []);
|
||||
|
||||
if ($provider === null) {
|
||||
$rules['disable_registration_when_oauth_enabled'] = 'boolean';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
public function mount(?string $provider = null)
|
||||
{
|
||||
if (! isInstanceAdmin()) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
$this->selectedProvider = $provider;
|
||||
$this->disable_registration_when_oauth_enabled = (bool) instanceSettings()->disable_registration_when_oauth_enabled;
|
||||
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
|
||||
$carry[$setting->provider] = [
|
||||
'id' => $setting->id,
|
||||
'provider' => $setting->provider,
|
||||
'enabled' => $setting->enabled,
|
||||
'client_id' => $setting->client_id,
|
||||
'client_secret' => $setting->client_secret,
|
||||
'redirect_uri' => $setting->redirect_uri,
|
||||
'tenant' => $setting->tenant,
|
||||
'base_url' => $setting->base_url,
|
||||
];
|
||||
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
|
||||
|
||||
return $carry;
|
||||
}, []);
|
||||
|
||||
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
private function updateOauthSettings(?string $provider = null)
|
||||
private function updateOauthSettings(?string $provider = null): void
|
||||
{
|
||||
$this->validate($this->validationRules($provider));
|
||||
|
||||
if ($provider) {
|
||||
$oauthData = $this->oauth_settings_map[$provider];
|
||||
$oauth = OauthSetting::find($oauthData['id']);
|
||||
@@ -54,78 +79,126 @@ 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 (! $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),
|
||||
'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,
|
||||
'require_email_verified' => $setting->require_email_verified ?? true,
|
||||
'use_pkce' => $setting->use_pkce ?? true,
|
||||
'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,
|
||||
'label' => $this->providerLabel($setting->provider),
|
||||
];
|
||||
}
|
||||
|
||||
public function providerLabel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'oidc' => 'OpenID Connect',
|
||||
'gitlab' => 'GitLab',
|
||||
default => str($provider)->headline()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
public function instantSave(string $provider)
|
||||
@@ -137,9 +210,71 @@ class SettingsOauth extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
public function toggleProvider(string $provider)
|
||||
{
|
||||
$this->updateOauthSettings();
|
||||
$this->dispatch('success', 'Instance settings updated successfully!');
|
||||
try {
|
||||
if (! array_key_exists($provider, $this->oauth_settings_map)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
|
||||
$this->validateProviderCanBeEnabled($provider);
|
||||
}
|
||||
|
||||
$this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
|
||||
$this->updateOauthSettings($provider);
|
||||
} catch (\Exception $e) {
|
||||
$oauth = OauthSetting::where('provider', $provider)->first();
|
||||
if ($oauth) {
|
||||
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
|
||||
}
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateProviderCanBeEnabled(string $provider): void
|
||||
{
|
||||
$this->validate($this->validationRules($provider));
|
||||
|
||||
$oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
|
||||
if (! $oauth) {
|
||||
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
|
||||
}
|
||||
|
||||
$this->fillOauthSetting($oauth, [
|
||||
...$this->oauth_settings_map[$provider],
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
if (! $oauth->couldBeEnabled()) {
|
||||
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
|
||||
}
|
||||
|
||||
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
|
||||
throw new \Exception("OIDC scopes must include 'openid'.");
|
||||
}
|
||||
}
|
||||
|
||||
public function saveRegistrationPolicy(): void
|
||||
{
|
||||
$this->validate([
|
||||
'disable_registration_when_oauth_enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
instanceSettings()->update([
|
||||
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'Authentication settings updated successfully!');
|
||||
}
|
||||
|
||||
public function submit(): void
|
||||
{
|
||||
$this->updateOauthSettings($this->selectedProvider);
|
||||
|
||||
if ($this->selectedProvider === null) {
|
||||
$this->dispatch('success', 'Instance settings updated successfully!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class InstanceSettings extends Model
|
||||
'do_not_track',
|
||||
'is_auto_update_enabled',
|
||||
'is_registration_enabled',
|
||||
'disable_registration_when_oauth_enabled',
|
||||
'next_channel',
|
||||
'smtp_enabled',
|
||||
'smtp_from_address',
|
||||
@@ -64,6 +65,8 @@ class InstanceSettings extends Model
|
||||
|
||||
'allowed_ip_ranges' => 'array',
|
||||
'is_auto_update_enabled' => 'boolean',
|
||||
'is_registration_enabled' => 'boolean',
|
||||
'disable_registration_when_oauth_enabled' => 'boolean',
|
||||
'auto_update_frequency' => 'string',
|
||||
'update_check_frequency' => 'string',
|
||||
'sentinel_token' => 'encrypted',
|
||||
@@ -84,6 +87,19 @@ 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,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,18 @@ class OauthSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
|
||||
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'allow_registration' => 'boolean',
|
||||
'require_email_verified' => 'boolean',
|
||||
'use_pkce' => 'boolean',
|
||||
'clock_skew_seconds' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
protected function clientSecret(): Attribute
|
||||
{
|
||||
@@ -28,9 +39,46 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -499,6 +500,16 @@ 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.
|
||||
|
||||
@@ -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 Laravel\Telescope\TelescopeServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -28,7 +32,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->configurePasswords();
|
||||
$this->configureSanctumModel();
|
||||
$this->configureGitHubHttp();
|
||||
|
||||
$this->configureOidcSocialite();
|
||||
}
|
||||
|
||||
private function configureCommands(): void
|
||||
@@ -63,6 +67,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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -47,7 +48,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
$isFirstUser = User::count() === 0;
|
||||
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
if (! $settings->isPasswordRegistrationAllowed()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
@@ -60,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,
|
||||
]);
|
||||
});
|
||||
@@ -82,7 +83,7 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
$user->save();
|
||||
|
||||
// Check if user has a pending invitation they haven't accepted yet
|
||||
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
|
||||
$invitation = TeamInvitation::whereEmail($email)->first();
|
||||
if ($invitation && $invitation->isValid()) {
|
||||
// User is logging in for the first time after being invited
|
||||
// Attach them to the invited team if not already attached
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?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\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 === '') {
|
||||
throw new HttpException(403, 'OAuth provider did not return an email address');
|
||||
}
|
||||
|
||||
$user = $provider === 'oidc'
|
||||
? $this->resolveOidcUser($oauthUser, $oauthSetting, $email)
|
||||
: $this->resolveOauthUser($oauthUser, $oauthSetting, $email);
|
||||
|
||||
Auth::login($user);
|
||||
$team = $user->currentTeam() ?? $user->teams()->first() ?? $user->recreate_personal_team();
|
||||
session(['currentTeam' => $user->currentTeam = $team]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
|
||||
{
|
||||
$user = User::whereEmail($email)->first();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if (! $this->canCreateUser($oauthSetting)) {
|
||||
throw new HttpException(403, 'Registration is disabled');
|
||||
}
|
||||
|
||||
return $this->createUser($oauthUser->name ?: $email, $email);
|
||||
}
|
||||
|
||||
private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
|
||||
{
|
||||
$issuer = $oauthUser instanceof OidcUser && filled($oauthUser->issuer)
|
||||
? $oauthUser->issuer
|
||||
: data_get($oauthUser->user, 'iss');
|
||||
$subject = $oauthUser instanceof OidcUser && filled($oauthUser->subject)
|
||||
? $oauthUser->subject
|
||||
: data_get($oauthUser->user, 'sub', $oauthUser->id);
|
||||
$emailVerified = ($oauthUser instanceof OidcUser && $oauthUser->emailVerified)
|
||||
|| data_get($oauthUser->user, 'email_verified') === true;
|
||||
|
||||
if (! is_string($issuer) || $issuer === '' || ! is_string($subject) || $subject === '') {
|
||||
throw new HttpException(403, 'OIDC provider did not return issuer and subject claims');
|
||||
}
|
||||
|
||||
if ($oauthSetting->require_email_verified && ! $emailVerified) {
|
||||
throw new HttpException(403, 'OIDC provider did not verify the email address');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $issuer, $subject) {
|
||||
$identity = OauthIdentity::where([
|
||||
'provider' => 'oidc',
|
||||
'issuer' => $issuer,
|
||||
'provider_user_id' => $subject,
|
||||
])->first();
|
||||
|
||||
if ($identity) {
|
||||
$identity->update([
|
||||
'email' => $email,
|
||||
'raw_claims' => $oauthUser->user,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
|
||||
return $identity->user;
|
||||
}
|
||||
|
||||
$user = User::whereEmail($email)->first();
|
||||
if (! $user) {
|
||||
if (! $this->canCreateUser($oauthSetting)) {
|
||||
throw new HttpException(403, 'Registration is disabled');
|
||||
}
|
||||
|
||||
$user = $this->createUser($oauthUser->name ?: $email, $email);
|
||||
}
|
||||
|
||||
OauthIdentity::create([
|
||||
'user_id' => $user->id,
|
||||
'provider' => 'oidc',
|
||||
'issuer' => $issuer,
|
||||
'provider_user_id' => $subject,
|
||||
'email' => $email,
|
||||
'raw_claims' => $oauthUser->user,
|
||||
'last_login_at' => now(),
|
||||
]);
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
|
||||
private function canCreateUser(OauthSetting $oauthSetting): bool
|
||||
{
|
||||
return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration;
|
||||
}
|
||||
|
||||
private function createUser(string $name, string $email): User
|
||||
{
|
||||
if (User::count() === 0) {
|
||||
$user = (new User)->forceFill([
|
||||
'id' => 0,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
]);
|
||||
$user->save();
|
||||
|
||||
$team = $user->teams()->first() ?? Team::find(0);
|
||||
if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||
$user->teams()->attach($team, ['role' => 'owner']);
|
||||
}
|
||||
|
||||
instanceSettings()->update(['is_registration_enabled' => false]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
return User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Hash::make(Str::random(64)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -60,6 +60,14 @@ return [
|
||||
'tenant' => env('GOOGLE_TENANT'),
|
||||
],
|
||||
|
||||
'oidc' => [
|
||||
'client_id' => env('OIDC_CLIENT_ID'),
|
||||
'client_secret' => env('OIDC_CLIENT_SECRET'),
|
||||
'redirect' => env('OIDC_REDIRECT_URI'),
|
||||
'base_url' => env('OIDC_BASE_URL'),
|
||||
'custom_label' => env('OIDC_LOGIN_LABEL'),
|
||||
],
|
||||
|
||||
'zitadel' => [
|
||||
'client_id' => env('ZITADEL_CLIENT_ID'),
|
||||
'client_secret' => env('ZITADEL_CLIENT_SECRET'),
|
||||
|
||||
+8
@@ -11,12 +11,20 @@ return new class extends Migration
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
|
||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,7 @@ class OauthSettingSeeder extends Seeder
|
||||
'github',
|
||||
'gitlab',
|
||||
'google',
|
||||
'oidc',
|
||||
'authentik',
|
||||
'infomaniak',
|
||||
'zitadel',
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
@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") }}
|
||||
{{ $provider_setting->loginLabel() }}
|
||||
</x-forms.button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
href="{{ route('settings.index') }}">
|
||||
Configuration
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('settings.backup') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.backup') }}">
|
||||
Backup
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('settings.email') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.email') }}">
|
||||
Transactional Email
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('settings.oauth') ? 'dark:text-white' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.oauth') }}">
|
||||
OAuth
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
href="{{ route('settings.index') }}"><span class="menu-item-label">General</span></a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'advanced' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.advanced') }}"><span class="menu-item-label">Advanced</span></a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'backup' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.backup') }}"><span class="menu-item-label">Instance Backup</span></a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'email' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.email') }}"><span class="menu-item-label">Transactional Email</span></a>
|
||||
<a class="sub-menu-item {{ $activeMenu === 'updates' ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.updates') }}"><span class="menu-item-label">Updates</span></a>
|
||||
</div>
|
||||
|
||||
@@ -10,18 +10,20 @@
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if ($discordEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="toggleDiscordEnabled">
|
||||
Disable Discord
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" class="normal-case dark:text-white btn btn-xs no-animation btn-primary"
|
||||
wire:click="sendTestNotification">
|
||||
Send Test Notification
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" disabled class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
|
||||
Send Test Notification
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="toggleDiscordEnabled">
|
||||
Enable Discord
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveDiscordEnabled" id="discordEnabled" label="Enabled" />
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveDiscordPingEnabled" id="discordPingEnabled"
|
||||
helper="If enabled, a ping (@here) will be sent to the notification when a critical event happens."
|
||||
label="Ping Enabled" />
|
||||
|
||||
@@ -21,10 +21,6 @@
|
||||
</x-forms.button>
|
||||
</form>
|
||||
</x-modal-input>
|
||||
@else
|
||||
<x-forms.button disabled class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
|
||||
Send Test Email
|
||||
</x-forms.button>
|
||||
@endif
|
||||
@endcan
|
||||
@endif
|
||||
@@ -63,10 +59,15 @@
|
||||
<x-forms.button canGate="update" :canResource="$settings" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" wire:model="smtpEnabled" instantSave="instantSave('SMTP')" id="smtpEnabled"
|
||||
label="Enabled" />
|
||||
@if ($smtpEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="toggleSmtp">
|
||||
Disable SMTP Server
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="toggleSmtp">
|
||||
Enable SMTP Server
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -95,10 +96,15 @@
|
||||
<x-forms.button canGate="update" :canResource="$settings" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" wire:model="resendEnabled" instantSave="instantSave('Resend')" id="resendEnabled"
|
||||
label="Enabled" />
|
||||
@if ($resendEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="toggleResend">
|
||||
Disable Resend
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="toggleResend">
|
||||
Enable Resend
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if ($pushoverEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="togglePushoverEnabled">
|
||||
Disable Pushover
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" class="normal-case dark:text-white btn btn-xs no-animation btn-primary"
|
||||
wire:click="sendTestNotification">
|
||||
Send Test Notification
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" disabled class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
|
||||
Send Test Notification
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="togglePushoverEnabled">
|
||||
Enable Pushover
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSavePushoverEnabled" id="pushoverEnabled" label="Enabled" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$settings" type="password"
|
||||
helper="Get your User Key in Pushover. You need to be logged in to Pushover to see your user key in the top right corner. <br><a class='inline-block underline dark:text-white' href='https://pushover.net/' target='_blank'>Pushover Dashboard</a>"
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if ($slackEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="toggleSlackEnabled">
|
||||
Disable Slack
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" class="normal-case dark:text-white btn btn-xs no-animation btn-primary"
|
||||
wire:click="sendTestNotification">
|
||||
Send Test Notification
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" disabled class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
|
||||
Send Test Notification
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="toggleSlackEnabled">
|
||||
Enable Slack
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveSlackEnabled" id="slackEnabled" label="Enabled" />
|
||||
</div>
|
||||
<x-forms.input canGate="update" :canResource="$settings" type="password"
|
||||
helper="Create a Slack APP and generate a Incoming Webhook URL. <br><a class='inline-block underline dark:text-white' href='https://api.slack.com/apps' target='_blank'>Create Slack APP</a>"
|
||||
required id="slackWebhookUrl" label="Webhook" />
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if ($telegramEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="toggleTelegramEnabled">
|
||||
Disable Telegram
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" class="normal-case dark:text-white btn btn-xs no-animation btn-primary"
|
||||
wire:click="sendTestNotification">
|
||||
Send Test Notification
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" disabled class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
|
||||
Send Test Notification
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="toggleTelegramEnabled">
|
||||
Enable Telegram
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveTelegramEnabled" id="telegramEnabled" label="Enabled" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$settings" type="password" autocomplete="new-password"
|
||||
helper="Get it from the <a class='inline-block underline dark:text-white' href='https://t.me/botfather' target='_blank'>BotFather Bot</a> on Telegram."
|
||||
|
||||
@@ -10,22 +10,20 @@
|
||||
Save
|
||||
</x-forms.button>
|
||||
@if ($webhookEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$settings" wire:click="toggleWebhookEnabled">
|
||||
Disable Webhook
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings"
|
||||
class="normal-case dark:text-white btn btn-xs no-animation btn-primary"
|
||||
wire:click="sendTestNotification">
|
||||
Send Test Notification
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="sendTest" :canResource="$settings" disabled
|
||||
class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
|
||||
Send Test Notification
|
||||
<x-forms.button canGate="update" :canResource="$settings" isHighlighted wire:click="toggleWebhookEnabled">
|
||||
Enable Webhook
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveWebhookEnabled"
|
||||
id="webhookEnabled" label="Enabled" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
|
||||
<x-forms.input canGate="update" :canResource="$settings" type="password"
|
||||
|
||||
@@ -11,16 +11,27 @@
|
||||
<div class="flex flex-col gap-2 lg:flex-row items-end">
|
||||
<x-forms.input id="name" label="Name" required />
|
||||
<x-forms.input id="email" label="Email" readonly />
|
||||
@if (!$show_email_change && !$show_verification)
|
||||
@if ($uses_sso)
|
||||
<x-forms.button type="button" disabled>Change Email</x-forms.button>
|
||||
@elseif (!$show_email_change && !$show_verification)
|
||||
<x-forms.button wire:click="showEmailChangeForm" type="button">Change Email</x-forms.button>
|
||||
@else
|
||||
<x-forms.button wire:click="showEmailChangeForm" type="button" disabled>Change Email</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
@if ($uses_sso)
|
||||
<div class="pt-2 text-sm dark:text-white">
|
||||
Signed in with SSO
|
||||
@if ($sso_provider_label)
|
||||
<span class="text-helper">({{ $sso_provider_label }})</span>
|
||||
@endif
|
||||
<span class="text-helper">Email is managed by your SSO provider.</span>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
<div class="flex flex-col pt-4">
|
||||
@if ($show_email_change)
|
||||
@if (! $uses_sso && $show_email_change)
|
||||
<form wire:submit='requestEmailChange'>
|
||||
<div class="flex gap-2 items-end">
|
||||
<x-forms.input id="new_email" label="New Email Address" required type="email" />
|
||||
@@ -34,7 +45,7 @@
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($show_verification)
|
||||
@if (! $uses_sso && $show_verification)
|
||||
<form wire:submit='verifyEmailChange'>
|
||||
<div class="flex gap-2 items-end">
|
||||
<x-forms.input id="email_verification_code" label="Verification Code (6 digits)" required
|
||||
|
||||
@@ -9,19 +9,29 @@
|
||||
@if ($server->isFunctional())
|
||||
<div class="flex gap-2 items-center">
|
||||
<h2>Log Drains</h2>
|
||||
<x-loading wire:target="instantSave" wire:loading.delay />
|
||||
<x-loading wire:target="toggleLogDrain" wire:loading.delay />
|
||||
</div>
|
||||
<div>Sends service logs to 3rd party tools.</div>
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<div class="p-4 border dark:border-coolgray-300 border-neutral-200">
|
||||
<form wire:submit='submit("newrelic")' class="flex flex-col">
|
||||
<h3>New Relic</h3>
|
||||
<div class="w-32">
|
||||
@if ($isLogDrainAxiomEnabled || $isLogDrainCustomEnabled)
|
||||
<x-forms.checkbox disabled id="isLogDrainNewRelicEnabled" label="Enabled" />
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>New Relic</h3>
|
||||
@if ($isLogDrainNewRelicEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$server" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleLogDrain('newrelic')">
|
||||
Disable New Relic
|
||||
</x-forms.button>
|
||||
@elseif ($isLogDrainAxiomEnabled || $isLogDrainCustomEnabled)
|
||||
<x-forms.button disabled>
|
||||
Enable New Relic
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$server"
|
||||
id="isLogDrainNewRelicEnabled" label="Enabled" />
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click="toggleLogDrain('newrelic')">
|
||||
Enable New Relic
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -51,16 +61,26 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Axiom</h3>
|
||||
<div class="w-32">
|
||||
@if ($isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled)
|
||||
<x-forms.checkbox disabled id="isLogDrainAxiomEnabled" label="Enabled" />
|
||||
@else
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$server"
|
||||
id="isLogDrainAxiomEnabled" label="Enabled" />
|
||||
@endif
|
||||
</div>
|
||||
<form wire:submit='submit("axiom")' class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>Axiom</h3>
|
||||
@if ($isLogDrainAxiomEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$server" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleLogDrain('axiom')">
|
||||
Disable Axiom
|
||||
</x-forms.button>
|
||||
@elseif ($isLogDrainNewRelicEnabled || $isLogDrainCustomEnabled)
|
||||
<x-forms.button disabled>
|
||||
Enable Axiom
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click="toggleLogDrain('axiom')">
|
||||
Enable Axiom
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full gap-2 xl:flex-row">
|
||||
@if ($server->isLogDrainEnabled())
|
||||
@@ -82,16 +102,26 @@
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</form>
|
||||
<h3>Custom FluentBit</h3>
|
||||
<div class="w-32">
|
||||
@if ($isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled)
|
||||
<x-forms.checkbox disabled id="isLogDrainCustomEnabled" label="Enabled" />
|
||||
@else
|
||||
<x-forms.checkbox instantSave canGate="update" :canResource="$server"
|
||||
id="isLogDrainCustomEnabled" label="Enabled" />
|
||||
@endif
|
||||
</div>
|
||||
<form wire:submit='submit("custom")' class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>Custom FluentBit</h3>
|
||||
@if ($isLogDrainCustomEnabled)
|
||||
<x-forms.button canGate="update" :canResource="$server" type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="toggleLogDrain('custom')">
|
||||
Disable Custom FluentBit
|
||||
</x-forms.button>
|
||||
@elseif ($isLogDrainNewRelicEnabled || $isLogDrainAxiomEnabled)
|
||||
<x-forms.button disabled>
|
||||
Enable Custom FluentBit
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button canGate="update" :canResource="$server" isHighlighted wire:click="toggleLogDrain('custom')">
|
||||
Enable Custom FluentBit
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
@if ($server->isLogDrainEnabled())
|
||||
<x-forms.textarea disabled rows="6" required id="logDrainCustomConfig"
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
Settings | Coolify
|
||||
</x-slot>
|
||||
<x-settings.navbar />
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h2>Backup</h2>
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }"
|
||||
class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-settings.sidebar activeMenu="backup" />
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Instance Backup</h2>
|
||||
@if (isset($database) && $server->isFunctional())
|
||||
<x-forms.button type="submit" wire:click="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="pb-4">Backup configuration for Coolify instance.</div>
|
||||
<div class="pb-4">Instance backup configuration for Coolify instance.</div>
|
||||
<div>
|
||||
@if ($server->isFunctional())
|
||||
@if (isset($database) && isset($backup))
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
Transactional Email | Coolify
|
||||
</x-slot>
|
||||
<x-settings.navbar />
|
||||
<form wire:submit='submit' class="flex flex-col gap-2 pb-4">
|
||||
<div x-data="{ activeTab: window.location.hash ? window.location.hash.substring(1) : 'general' }"
|
||||
class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<x-settings.sidebar activeMenu="email" />
|
||||
<div class="flex flex-col w-full">
|
||||
<form wire:submit='submit' class="flex flex-col pb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2>Transactional Email</h2>
|
||||
<x-forms.button type="submit">
|
||||
@@ -27,17 +31,22 @@
|
||||
<x-forms.input required id="smtpFromAddress" helper="Email address used in emails." label="From Address" />
|
||||
</div>
|
||||
</form>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="p-4 border dark:border-coolgray-300 border-neutral-200">
|
||||
<div class="flex flex-col gap-4">
|
||||
<form wire:submit.prevent="submitSmtp" class="flex flex-col">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>SMTP Server</h3>
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox instantSave='instantSave("SMTP")' id="smtpEnabled" label="Enabled" />
|
||||
@if ($smtpEnabled)
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="toggleSmtp">
|
||||
Disable SMTP Server
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button isHighlighted wire:click="toggleSmtp">
|
||||
Enable SMTP Server
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full gap-2 xl:flex-row">
|
||||
@@ -57,17 +66,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="p-4 border dark:border-coolgray-300 border-neutral-200">
|
||||
<form wire:submit.prevent="submitResend" class="flex flex-col">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>Resend</h3>
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox instantSave='instantSave("Resend")' id="resendEnabled" label="Enabled" />
|
||||
@if ($resendEnabled)
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="toggleResend">
|
||||
Disable Resend
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button isHighlighted wire:click="toggleResend">
|
||||
Enable Resend
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col w-full gap-2 xl:flex-row">
|
||||
@@ -76,6 +89,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,51 +3,131 @@
|
||||
Settings | Coolify
|
||||
</x-slot>
|
||||
<x-settings.navbar />
|
||||
<form wire:submit='submit' class="flex flex-col">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h2>Authentication</h2>
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="pb-4 ">Custom authentication (OAuth) configurations.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 pt-4">
|
||||
@foreach ($oauth_settings_map as $oauth_setting)
|
||||
<div class="p-4 border dark:border-coolgray-300 border-neutral-200">
|
||||
<h3>{{ ucfirst($oauth_setting['provider']) }}</h3>
|
||||
<div class="w-32">
|
||||
<x-forms.checkbox instantSave="instantSave('{{ $oauth_setting['provider'] }}')"
|
||||
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.enabled" label="Enabled" />
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2 xl:flex-row">
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.client_id"
|
||||
label="Client ID" />
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.client_secret"
|
||||
type="password" label="Client Secret" autocomplete="new-password" />
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.redirect_uri"
|
||||
placeholder="{{ route('auth.callback', $oauth_setting['provider']) }}" label="Redirect URI" />
|
||||
@if ($oauth_setting['provider'] == 'azure')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.tenant"
|
||||
label="Tenant" />
|
||||
@endif
|
||||
@if ($oauth_setting['provider'] == 'google')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.tenant"
|
||||
helper="Optional parameter that supplies a hosted domain (HD) to Google, which<br>triggers a login hint to be displayed on the OAuth screen with this domain.<br><br><a class='underline dark:text-warning text-coollabs' href='https://developers.google.com/identity/openid-connect/openid-connect#hd-param' target='_blank'>Google Documentation</a>"
|
||||
label="Tenant" />
|
||||
@endif
|
||||
@if (
|
||||
$oauth_setting['provider'] == 'authentik' ||
|
||||
$oauth_setting['provider'] == 'clerk' ||
|
||||
$oauth_setting['provider'] == 'zitadel' ||
|
||||
$oauth_setting['provider'] == 'gitlab')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.base_url"
|
||||
label="Base URL" />
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col h-full gap-8 sm:flex-row">
|
||||
<div class="sub-menu-wrapper">
|
||||
<a class="sub-menu-item {{ $selectedProvider === null ? 'menu-item-active' : '' }}" {{ wireNavigate() }}
|
||||
href="{{ route('settings.oauth') }}"><span class="menu-item-label">General</span></a>
|
||||
@foreach ($oauth_settings_map as $provider => $oauth_setting)
|
||||
<a class="sub-menu-item {{ $selectedProvider === $provider ? 'menu-item-active' : '' }}"
|
||||
{{ wireNavigate() }} href="{{ route('settings.oauth.provider', $provider) }}"><span
|
||||
class="menu-item-label">{{ $oauth_setting['label'] }}</span></a>
|
||||
@endforeach
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form wire:submit='submit' class="flex flex-col w-full">
|
||||
@if ($selectedProvider === null)
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h2>Authentication</h2>
|
||||
</div>
|
||||
<div class="pb-4">General authentication settings for your Coolify instance.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 pt-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3>Registration</h3>
|
||||
<x-helper
|
||||
helper="When enabled, the normal registration page is hidden if at least one OAuth provider is enabled. OAuth providers can still create users if their provider-specific registration option allows it." />
|
||||
</div>
|
||||
<div class="w-full max-w-2xl">
|
||||
<x-forms.checkbox id="disable_registration_when_oauth_enabled"
|
||||
label="Disable password registration when OAuth is enabled"
|
||||
instantSave="saveRegistrationPolicy" fullWidth />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
@php
|
||||
$oauth_setting = $oauth_settings_map[$selectedProvider] ?? null;
|
||||
@endphp
|
||||
|
||||
@if ($oauth_setting)
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<h2>{{ $oauth_setting['label'] }}</h2>
|
||||
@if ($oauth_setting['enabled'])
|
||||
<x-forms.button type="submit">
|
||||
Save
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="toggleProvider('{{ $oauth_setting['provider'] }}')">
|
||||
Disable {{ $oauth_setting['label'] }}
|
||||
</x-forms.button>
|
||||
@else
|
||||
<x-forms.button isHighlighted wire:click="toggleProvider('{{ $oauth_setting['provider'] }}')">
|
||||
Enable {{ $oauth_setting['label'] }}
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="pb-4">OAuth configuration for {{ $oauth_setting['label'] }}.</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 pt-4">
|
||||
<div>
|
||||
<div class="flex flex-col w-full gap-2 xl:flex-row">
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.client_id"
|
||||
label="Client ID" />
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.client_secret"
|
||||
type="password" label="Client Secret" autocomplete="new-password" />
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.redirect_uri"
|
||||
placeholder="{{ route('auth.callback', $oauth_setting['provider']) }}"
|
||||
label="Redirect URI" />
|
||||
@if ($oauth_setting['provider'] == 'azure')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.tenant"
|
||||
label="Tenant" />
|
||||
@endif
|
||||
@if ($oauth_setting['provider'] == 'google')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.tenant"
|
||||
helper="Optional parameter that supplies a hosted domain (HD) to Google, which<br>triggers a login hint to be displayed on the OAuth screen with this domain.<br><br><a class='underline dark:text-warning text-coollabs' href='https://developers.google.com/identity/openid-connect/openid-connect#hd-param' target='_blank'>Google Documentation</a>"
|
||||
label="Tenant" />
|
||||
@endif
|
||||
@if (
|
||||
$oauth_setting['provider'] == 'authentik' ||
|
||||
$oauth_setting['provider'] == 'clerk' ||
|
||||
$oauth_setting['provider'] == 'zitadel' ||
|
||||
$oauth_setting['provider'] == 'gitlab')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.base_url"
|
||||
label="Base URL" />
|
||||
@endif
|
||||
@if ($oauth_setting['provider'] == 'oidc')
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.base_url"
|
||||
label="Issuer URL"
|
||||
helper="OpenID Provider issuer URL, for example https://example.okta.com. Coolify uses this URL to discover authorization, token, userinfo, and JWKS endpoints." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($oauth_setting['provider'] == 'oidc')
|
||||
<div class="flex flex-col w-full gap-2 pt-2 xl:flex-row">
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.custom_label"
|
||||
label="Login Button Label" placeholder="Login with SSO" />
|
||||
<x-forms.input id="oauth_settings_map.{{ $oauth_setting['provider'] }}.scopes"
|
||||
label="Scopes"
|
||||
helper="Must include openid. Common Okta scopes: openid email profile groups." />
|
||||
<x-forms.input
|
||||
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.clock_skew_seconds"
|
||||
type="number" label="Clock Skew (seconds)" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 pt-2">
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox
|
||||
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.allow_registration"
|
||||
label="Allow OIDC user creation"
|
||||
helper="When enabled, a successful OIDC login can create a Coolify user even if normal password registration is disabled." />
|
||||
</div>
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox
|
||||
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.require_email_verified"
|
||||
label="Require verified email" />
|
||||
</div>
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox
|
||||
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.use_pkce"
|
||||
label="Use PKCE" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,11 @@
|
||||
shortConfirmationLabel="Confirmation text" />
|
||||
</div>
|
||||
@endif
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox instantSave id="disable_registration_when_oauth_enabled"
|
||||
helper="When enabled, the normal registration page is hidden if at least one OAuth provider is enabled. OAuth providers can still create users if their provider-specific registration option allows it."
|
||||
label="Disable Registration When OAuth Is Enabled" />
|
||||
</div>
|
||||
<div class="md:w-96">
|
||||
<x-forms.checkbox instantSave id="do_not_track"
|
||||
helper="Opt out of anonymous usage tracking. When enabled, this instance will not report to coolify.io's installation count and will not send error reports to help improve Coolify."
|
||||
|
||||
@@ -124,6 +124,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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -4,22 +4,26 @@ use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create([
|
||||
InstanceSettings::forceCreate([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => false,
|
||||
]);
|
||||
|
||||
Once::flush();
|
||||
|
||||
OauthSetting::create([
|
||||
'provider' => 'google',
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
|
||||
'tenant' => 'example.com',
|
||||
'enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,146 @@
|
||||
<?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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
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('creates a new oidc user when provider registration is allowed while normal registration is disabled', function () {
|
||||
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
|
||||
|
||||
fakeOidcProvider(['email' => 'newuser@example.com']);
|
||||
|
||||
$response = $this->get(route('auth.callback', 'oidc'));
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$user = User::whereEmail('newuser@example.com')->first();
|
||||
expect($user)->not->toBeNull()
|
||||
->and($user->password)->not->toBeNull();
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertDatabaseHas('oauth_identities', [
|
||||
'user_id' => $user->id,
|
||||
'provider' => 'oidc',
|
||||
'issuer' => 'https://idp.example.com',
|
||||
'provider_user_id' => 'okta-user-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects new oidc users when neither normal nor provider registration is enabled', function () {
|
||||
fakeOidcProvider(['email' => 'blocked@example.com']);
|
||||
|
||||
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates the root user when oidc provisions the first account', function () {
|
||||
Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
|
||||
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
|
||||
|
||||
fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']);
|
||||
|
||||
$response = $this->get(route('auth.callback', 'oidc'));
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']);
|
||||
$this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']);
|
||||
});
|
||||
|
||||
it('rejects callbacks for disabled oidc provider', function () {
|
||||
OauthSetting::where('provider', 'oidc')->update(['enabled' => false]);
|
||||
|
||||
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('logs callback failures with diagnostic context', function () {
|
||||
Log::spy();
|
||||
|
||||
$provider = Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->andReturnSelf();
|
||||
$provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed'));
|
||||
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
|
||||
|
||||
$response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value']));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) {
|
||||
return $message === 'OAuth callback failed.'
|
||||
&& $context['provider'] === 'oidc'
|
||||
&& $context['exception_class'] === RuntimeException::class
|
||||
&& $context['exception_message'] === 'Token exchange failed'
|
||||
&& $context['has_code'] === true
|
||||
&& $context['has_state'] === true
|
||||
&& $context['exception'] instanceof RuntimeException;
|
||||
});
|
||||
});
|
||||
@@ -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,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,229 @@
|
||||
<?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 () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]);
|
||||
Once::flush();
|
||||
OauthSetting::create(['provider' => 'oidc']);
|
||||
OauthSetting::create(['provider' => 'authentik']);
|
||||
OauthSetting::create(['provider' => 'bitbucket']);
|
||||
});
|
||||
|
||||
it('shows oauth general settings with provider subnavigation', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get(route('settings.oauth'))
|
||||
->assertSuccessful()
|
||||
->assertSee('General')
|
||||
->assertSee('Authentik')
|
||||
->assertSee('Bitbucket')
|
||||
->assertSee(route('settings.oauth.provider', 'authentik'), false)
|
||||
->assertSee(route('settings.oauth.provider', 'bitbucket'), false)
|
||||
->assertSee('Disable password registration when OAuth is enabled')
|
||||
->assertDontSee('Client Secret');
|
||||
});
|
||||
|
||||
it('shows the registration helper next to the section title in a wider row', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get(route('settings.oauth'))
|
||||
->assertSuccessful()
|
||||
->assertSee('flex items-center gap-2', false)
|
||||
->assertSee('max-w-2xl', false)
|
||||
->assertSee('Disable password registration when OAuth is enabled')
|
||||
->assertDontSee('md:w-96', false);
|
||||
});
|
||||
|
||||
it('auto saves registration policy without a general save button', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get(route('settings.oauth'))
|
||||
->assertSuccessful()
|
||||
->assertSee("wire:click='saveRegistrationPolicy'", false)
|
||||
->assertDontSee('Save</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 a provider settings page with a naked okta issuer url example', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get(route('settings.oauth.provider', 'oidc'))
|
||||
->assertSuccessful()
|
||||
->assertSee('OpenID Connect')
|
||||
->assertSee('https://example.okta.com', false)
|
||||
->assertDontSee('/oauth2/default', false);
|
||||
});
|
||||
|
||||
it('shows provider enable controls as actions without boxed sections', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get(route('settings.oauth.provider', 'authentik'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Enable Authentik')
|
||||
->assertDontSee('label="Enabled"', false)
|
||||
->assertDontSee('p-4 border dark:border-coolgray-300 border-neutral-200', false);
|
||||
});
|
||||
|
||||
it('stacks oidc option checkboxes vertically', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get(route('settings.oauth.provider', 'oidc'))
|
||||
->assertSuccessful()
|
||||
->assertSee('Allow OIDC user creation')
|
||||
->assertSee('Require verified email')
|
||||
->assertSee('Use PKCE')
|
||||
->assertDontSee('flex flex-col gap-2 pt-2 md:flex-row', false);
|
||||
});
|
||||
|
||||
it('does not show unknown oauth providers', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
$this->withoutMiddleware(DecideWhatToDoWithUser::class)
|
||||
->get('/settings/oauth/unknown')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('defaults oidc user creation and verified email requirement to enabled', function () {
|
||||
$setting = OauthSetting::where('provider', 'oidc')->first();
|
||||
|
||||
expect($setting->allow_registration)->toBeTrue()
|
||||
->and($setting->require_email_verified)->toBeTrue();
|
||||
});
|
||||
|
||||
it('persists oidc oauth settings from livewire', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
Livewire::test(SettingsOauth::class)
|
||||
->set('oauth_settings_map.oidc.enabled', true)
|
||||
->set('oauth_settings_map.oidc.client_id', 'client-id')
|
||||
->set('oauth_settings_map.oidc.client_secret', 'secret')
|
||||
->set('oauth_settings_map.oidc.redirect_uri', 'https://coolify.example.com/auth/oidc/callback')
|
||||
->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
|
||||
->set('oauth_settings_map.oidc.scopes', 'openid email profile groups')
|
||||
->set('oauth_settings_map.oidc.custom_label', 'Login with Okta')
|
||||
->set('oauth_settings_map.oidc.allow_registration', true)
|
||||
->set('oauth_settings_map.oidc.require_email_verified', true)
|
||||
->set('disable_registration_when_oauth_enabled', true)
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$setting = OauthSetting::where('provider', 'oidc')->first();
|
||||
expect($setting->enabled)->toBeTrue()
|
||||
->and($setting->redirect_uri)->toBe('https://coolify.example.com/auth/oidc/callback')
|
||||
->and($setting->base_url)->toBe('https://idp.example.com')
|
||||
->and($setting->custom_label)->toBe('Login with Okta')
|
||||
->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
|
||||
->and($setting->allow_registration)->toBeTrue();
|
||||
|
||||
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
|
||||
});
|
||||
|
||||
it('saves only the selected provider from provider pages', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
|
||||
->set('oauth_settings_map.oidc.redirect_uri', 'not-a-url')
|
||||
->set('oauth_settings_map.authentik.enabled', true)
|
||||
->set('oauth_settings_map.authentik.client_id', 'authentik-client')
|
||||
->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
|
||||
->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
|
||||
->call('submit')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$setting = OauthSetting::where('provider', 'authentik')->first();
|
||||
expect($setting->enabled)->toBeTrue()
|
||||
->and($setting->client_id)->toBe('authentik-client')
|
||||
->and($setting->base_url)->toBe('https://authentik.example.com');
|
||||
});
|
||||
|
||||
it('validates oidc url fields before saving', function (string $field, string $value) {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
Livewire::test(SettingsOauth::class)
|
||||
->set('oauth_settings_map.oidc.client_id', 'client-id')
|
||||
->set('oauth_settings_map.oidc.client_secret', 'secret')
|
||||
->set('oauth_settings_map.oidc.base_url', 'https://idp.example.com')
|
||||
->set("oauth_settings_map.oidc.$field", $value)
|
||||
->call('submit')
|
||||
->assertHasErrors(["oauth_settings_map.oidc.$field" => 'url']);
|
||||
|
||||
$setting = OauthSetting::where('provider', 'oidc')->first();
|
||||
expect($setting->{$field})->toBeNull();
|
||||
})->with([
|
||||
'invalid redirect uri' => ['redirect_uri', 'not-a-url'],
|
||||
'non-http redirect uri' => ['redirect_uri', 'javascript:alert(1)'],
|
||||
'invalid issuer url' => ['base_url', 'not-a-url'],
|
||||
'non-http issuer url' => ['base_url', 'ftp://idp.example.com'],
|
||||
]);
|
||||
|
||||
it('does not enable oidc without required fields', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
Livewire::test(SettingsOauth::class)
|
||||
->set('oauth_settings_map.oidc.enabled', true)
|
||||
->call('instantSave', 'oidc')
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(OauthSetting::where('provider', 'oidc')->first()->enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps provider disabled in the ui when enable validation fails', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
|
||||
->call('toggleProvider', 'authentik')
|
||||
->assertDispatched('error')
|
||||
->assertSet('oauth_settings_map.authentik.enabled', false);
|
||||
|
||||
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
it('toggles provider enabled state from the action button', function () {
|
||||
actingAsInstanceAdmin();
|
||||
|
||||
Livewire::test(SettingsOauth::class, ['provider' => 'authentik'])
|
||||
->set('oauth_settings_map.authentik.client_id', 'authentik-client')
|
||||
->set('oauth_settings_map.authentik.client_secret', 'authentik-secret')
|
||||
->set('oauth_settings_map.authentik.base_url', 'https://authentik.example.com')
|
||||
->call('toggleProvider', 'authentik')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(OauthSetting::where('provider', 'authentik')->first()->enabled)->toBeTrue();
|
||||
});
|
||||
@@ -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,57 @@
|
||||
<?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('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);
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
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 mismatch' => [['aud' => ['client-id', 'other-client'], 'azp' => 'other-client'], 'azp'],
|
||||
'expired token' => [['exp' => time() - 3600], 'expired'],
|
||||
'future issued at' => [['iat' => time() + 3600], 'issued'],
|
||||
'nonce mismatch' => [['nonce' => 'wrong-nonce'], 'nonce'],
|
||||
]);
|
||||
|
||||
it('rejects a bad signature and unknown key id', function (string $kid) {
|
||||
$keyset = oidc_keyset('test-key');
|
||||
$otherKeyset = oidc_keyset($kid);
|
||||
$now = time();
|
||||
$token = oidc_token([
|
||||
'iss' => 'https://idp.example.com',
|
||||
'aud' => 'client-id',
|
||||
'sub' => 'okta-user-1',
|
||||
'iat' => $now,
|
||||
'exp' => $now + 600,
|
||||
'nonce' => 'expected-nonce',
|
||||
], $otherKeyset['private_pem'], $kid);
|
||||
|
||||
app(OidcTokenValidator::class)->validate(
|
||||
idToken: $token,
|
||||
discovery: oidc_discovery(),
|
||||
jwks: $keyset['jwks'],
|
||||
clientId: 'client-id',
|
||||
expectedNonce: 'expected-nonce',
|
||||
);
|
||||
})->throws(OidcTokenException::class)->with([
|
||||
'same kid with bad signature' => ['test-key'],
|
||||
'unknown kid' => ['other-key'],
|
||||
]);
|
||||
|
||||
it('rejects disallowed algorithms', function () {
|
||||
$keyset = oidc_keyset();
|
||||
$now = time();
|
||||
$token = oidc_token([
|
||||
'iss' => 'https://idp.example.com',
|
||||
'aud' => 'client-id',
|
||||
'sub' => 'okta-user-1',
|
||||
'iat' => $now,
|
||||
'exp' => $now + 600,
|
||||
], $keyset['private_pem'], algorithm: 'HS256');
|
||||
|
||||
app(OidcTokenValidator::class)->validate($token, oidc_discovery(), $keyset['jwks'], 'client-id');
|
||||
})->throws(OidcTokenException::class);
|
||||
Reference in New Issue
Block a user