feat(auth): add OIDC SSO and registration controls (#10543)

This commit is contained in:
Andras Bacsai 2026-08-18 14:46:25 +02:00 committed by GitHub
commit 41b06e73a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 3629 additions and 305 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,34 @@
<?php
namespace App\Auth\Oidc;
use App\Models\OauthSetting;
final readonly class OidcConfig
{
/**
* @param array<int, string> $scopes
*/
public function __construct(
public string $issuerUrl,
public string $clientId,
public string $clientSecret,
public string $redirectUri,
public array $scopes = ['openid', 'email', 'profile'],
public bool $usePkce = true,
public int $clockSkewSeconds = 60,
) {}
public static function fromOauthSetting(OauthSetting $setting): self
{
return new self(
issuerUrl: rtrim((string) $setting->base_url, '/'),
clientId: (string) $setting->client_id,
clientSecret: (string) $setting->client_secret,
redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'),
scopes: $setting->scopeList(),
usePkce: $setting->use_pkce ?? true,
clockSkewSeconds: $setting->clock_skew_seconds ?? 60,
);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
final readonly class OidcDiscoveryDocument
{
/**
* @param array<int, string> $supportedScopes
* @param array<int, string> $supportedClaims
* @param array<int, string> $idTokenSigningAlgValuesSupported
*/
public function __construct(
public string $issuer,
public string $authorizationEndpoint,
public string $tokenEndpoint,
public string $userinfoEndpoint,
public string $jwksUri,
public ?string $endSessionEndpoint = null,
public array $supportedScopes = [],
public array $supportedClaims = [],
public array $idTokenSigningAlgValuesSupported = [],
) {}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) {
if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') {
throw new OidcDiscoveryException("Discovery document is missing required field: {$field}");
}
}
return new self(
issuer: $payload['issuer'],
authorizationEndpoint: $payload['authorization_endpoint'],
tokenEndpoint: $payload['token_endpoint'],
userinfoEndpoint: $payload['userinfo_endpoint'],
jwksUri: $payload['jwks_uri'],
endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null,
supportedScopes: self::stringList($payload['scopes_supported'] ?? []),
supportedClaims: self::stringList($payload['claims_supported'] ?? []),
idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []),
);
}
/**
* @return array<int, string>
*/
private static function stringList(mixed $value): array
{
if (! is_array($value)) {
return [];
}
return array_values(array_map('strval', $value));
}
}

View file

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

View file

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

View file

@ -0,0 +1,32 @@
<?php
namespace App\Auth\Oidc;
use Laravel\Socialite\Two\User as SocialiteUser;
class OidcUser extends SocialiteUser
{
public ?string $issuer = null;
public ?string $subject = null;
public bool $emailVerified = false;
/**
* @var array<string, mixed>
*/
public array $idTokenClaims = [];
/**
* @param array<string, mixed> $claims
*/
public function setIdTokenClaims(array $claims): self
{
$this->idTokenClaims = $claims;
$this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
$this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
$this->emailVerified = ($claims['email_verified'] ?? false) === true;
return $this;
}
}

View file

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

View file

@ -2,47 +2,60 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use App\Models\OauthSetting;
use App\Services\Auth\OauthLoginService;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpKernel\Exception\HttpException;
class OauthController extends Controller
{
public function redirect(string $provider)
{
$socialite_provider = get_socialite_provider($provider);
$oauthSetting = $this->enabledProvider($provider);
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
return $socialite_provider->redirect();
return $socialiteProvider->redirect();
}
public function callback(string $provider)
public function callback(string $provider, OauthLoginService $oauthLoginService)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
$email = trim((string) $oauthUser->email);
if ($email === '') {
abort(403, 'OAuth provider did not return an email address');
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
abort(403, 'Registration is disabled');
}
$user = User::create([
'name' => $oauthUser->name,
'email' => $email,
]);
}
Auth::login($user);
$oauthSetting = $this->enabledProvider($provider);
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
$oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
return redirect('/');
} catch (\Exception $e) {
$this->logCallbackFailure($provider, $e);
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';
return redirect()->route('login')->withErrors([__($errorCode)]);
}
}
private function logCallbackFailure(string $provider, \Throwable $exception): void
{
Log::error('OAuth callback failed.', [
'provider' => $provider,
'exception_class' => $exception::class,
'exception_message' => $exception->getMessage(),
'request_error' => request()->query('error'),
'request_error_description' => request()->query('error_description'),
'has_code' => request()->query->has('code'),
'has_state' => request()->query->has('state'),
'ip' => request()->ip(),
'exception' => $exception,
]);
}
private function enabledProvider(string $provider): OauthSetting
{
$oauthSetting = OauthSetting::where('provider', $provider)->first();
if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
throw new HttpException(403, 'OAuth provider is not enabled');
}
return $oauthSetting;
}
}

View file

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

View file

@ -2,7 +2,6 @@
namespace App\Livewire\Notifications;
use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings;
use App\Models\Team;
use App\Notifications\Test;
@ -14,7 +13,7 @@
class Email extends Component
{
use AuthorizesRequests, TogglesNotificationEvents;
use AuthorizesRequests;
protected $listeners = ['refresh' => '$refresh'];
@ -245,31 +244,59 @@ public function instantSave(?string $type = null)
}
}
public function toggleSmtp()
{
try {
$this->resetErrorBag();
if ($this->smtpEnabled) {
$this->smtpEnabled = false;
$this->saveModel();
} else {
$this->validateSmtpSettings();
$this->smtpEnabled = true;
$this->resendEnabled = false;
$this->submitSmtp();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function toggleResend()
{
try {
$this->resetErrorBag();
if ($this->resendEnabled) {
$this->resendEnabled = false;
$this->saveModel();
} else {
$this->validateResendSettings();
$this->resendEnabled = true;
$this->smtpEnabled = false;
$this->submitResend();
}
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
], [
'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;
@ -300,17 +327,7 @@ public function submitResend()
try {
$this->resetErrorBag();
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
$this->validateResendSettings();
if ($this->resendEnabled) {
$this->settings->smtp_enabled = $this->smtpEnabled = false;
}
@ -327,6 +344,44 @@ public function submitResend()
}
}
private function validateSmtpSettings(): void
{
$this->validate([
'smtpEnabled' => 'boolean',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
'smtpHost' => 'required|string',
'smtpPort' => 'required|numeric',
'smtpEncryption' => 'required|string|in:starttls,tls,none',
'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric',
], [
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
'smtpHost.required' => 'SMTP Host is required.',
'smtpPort.required' => 'SMTP Port is required.',
'smtpPort.numeric' => 'SMTP Port must be a number.',
'smtpEncryption.required' => 'Encryption type is required.',
]);
}
private function validateResendSettings(): void
{
$this->validate([
'resendEnabled' => 'boolean',
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [
'resendApiKey.required' => 'Resend API Key is required.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]);
}
public function sendTestEmail()
{
try {

View file

@ -159,6 +159,34 @@ public function instantSavePushoverEnabled()
}
}
public function togglePushoverEnabled()
{
try {
$this->resetErrorBag();
if ($this->pushoverEnabled) {
$this->pushoverEnabled = false;
} else {
$this->validate([
'pushoverUserKey' => 'required',
'pushoverApiToken' => 'required',
], [
'pushoverUserKey.required' => 'Pushover User Key is required.',
'pushoverApiToken.required' => 'Pushover API Token is required.',
]);
$this->pushoverEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {

View file

@ -150,6 +150,32 @@ public function instantSaveSlackEnabled()
}
}
public function toggleSlackEnabled()
{
try {
$this->resetErrorBag();
if ($this->slackEnabled) {
$this->slackEnabled = false;
} else {
$this->validate([
'slackWebhookUrl' => 'required',
], [
'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
]);
$this->slackEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
} finally {
$this->dispatch('refresh');
}
}
public function instantSave()
{
try {

View file

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

View file

@ -144,6 +144,30 @@ public function instantSaveWebhookEnabled()
}
}
public function toggleWebhookEnabled()
{
try {
$this->resetErrorBag();
if ($this->webhookEnabled) {
$this->webhookEnabled = false;
} else {
$this->validate([
'webhookUrl' => 'required',
], [
'webhookUrl.required' => 'Webhook URL is required.',
]);
$this->webhookEnabled = true;
}
$this->saveModel();
} catch (\Throwable $e) {
$this->syncData();
return handleError($e, $this);
}
}
public function instantSave()
{
try {

View file

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

View file

@ -177,6 +177,49 @@ public function instantSave()
}
}
public function toggleLogDrain(string $type): void
{
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
try {
$this->authorize('update', $this->server);
$this->resetErrorBag();
$enabledProperty = $this->enabledProperty($type);
if ($this->{$enabledProperty}) {
$this->{$enabledProperty} = false;
} else {
$this->validateLogDrainSettings($type);
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
$this->isLogDrainAxiomEnabled = $type === 'axiom';
$this->isLogDrainCustomEnabled = $type === 'custom';
}
$this->syncData(true);
if ($this->server->isLogDrainEnabled()) {
StartLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service started.');
} else {
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
$this->server->settings->save();
$this->syncData();
handleError($e, $this);
}
}
public function submit()
{
try {
@ -192,4 +235,33 @@ public function render()
{
return view('livewire.server.log-drains');
}
private function enabledProperty(string $type): string
{
return match ($type) {
'newrelic' => 'isLogDrainNewRelicEnabled',
'axiom' => 'isLogDrainAxiomEnabled',
'custom' => 'isLogDrainCustomEnabled',
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
private function validateLogDrainSettings(string $type): void
{
match ($type) {
'newrelic' => $this->validate([
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainNewRelicBaseUri' => ['required', 'url'],
]),
'axiom' => $this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
]),
'custom' => $this->validate([
'logDrainCustomConfig' => ['required'],
'logDrainCustomConfigParser' => ['string', 'nullable'],
]),
default => throw new \InvalidArgumentException('Unknown log drain type.'),
};
}
}

View file

@ -19,6 +19,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_registration_enabled;
#[Validate('boolean')]
public bool $disable_registration_when_oauth_enabled;
#[Validate('boolean')]
public bool $do_not_track;
@ -59,6 +62,7 @@ public function rules()
{
return [
'is_registration_enabled' => 'boolean',
'disable_registration_when_oauth_enabled' => 'boolean',
'do_not_track' => 'boolean',
'is_dns_validation_enabled' => 'boolean',
'custom_dns_servers' => ['nullable', 'string', new ValidDnsServers],
@ -84,6 +88,7 @@ public function mount()
$this->allowed_ips = $this->settings->allowed_ips;
$this->do_not_track = $this->settings->do_not_track;
$this->is_registration_enabled = $this->settings->is_registration_enabled;
$this->disable_registration_when_oauth_enabled = $this->settings->disable_registration_when_oauth_enabled;
$this->is_dns_validation_enabled = $this->settings->is_dns_validation_enabled;
$this->is_api_enabled = $this->settings->is_api_enabled;
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
@ -199,6 +204,7 @@ public function instantSave(?array $webhookAllowedInternalHosts = null)
try {
$this->authorize('update', $this->settings);
$this->settings->is_registration_enabled = $this->is_registration_enabled;
$this->settings->disable_registration_when_oauth_enabled = $this->disable_registration_when_oauth_enabled;
$this->settings->do_not_track = $this->do_not_track;
$this->settings->is_dns_validation_enabled = $this->is_dns_validation_enabled;
$this->settings->custom_dns_servers = $this->custom_dns_servers;

View file

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

View file

@ -2,53 +2,89 @@
namespace App\Livewire;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class SettingsOauth extends Component
{
use AuthorizesRequests;
public InstanceSettings $settings;
public $oauth_settings_map;
protected function rules()
public ?string $selectedProvider = null;
public bool $disable_registration_when_oauth_enabled = false;
protected function rules(): array
{
return OauthSetting::all()->reduce(function ($carry, $setting) {
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required';
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable';
return $this->validationRules();
}
private function validationRules(?string $provider = null): array
{
$rules = OauthSetting::all()->reduce(function ($carry, $setting) use ($provider) {
if ($provider !== null && $setting->provider !== $provider) {
return $carry;
}
$carry["oauth_settings_map.$setting->provider.enabled"] = 'required|boolean';
$carry["oauth_settings_map.$setting->provider.client_id"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.client_secret"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.redirect_uri"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.tenant"] = 'nullable|string';
$carry["oauth_settings_map.$setting->provider.base_url"] = 'nullable|string|max:2048|url:http,https';
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
return $carry;
}, []);
if ($provider === null) {
$rules['disable_registration_when_oauth_enabled'] = 'boolean';
}
return $rules;
}
public function mount()
public function mount(?string $provider = null): ?RedirectResponse
{
if (! isInstanceAdmin()) {
return redirect()->route('home');
}
$this->oauth_settings_map = OauthSetting::all()->sortBy('provider')->reduce(function ($carry, $setting) {
$carry[$setting->provider] = [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
];
return $carry;
}, []);
$this->settings = instanceSettings();
$this->selectedProvider = $provider;
$this->disable_registration_when_oauth_enabled = (bool) $this->settings->disable_registration_when_oauth_enabled;
$this->oauth_settings_map = OauthSetting::all()
->sortBy(fn (OauthSetting $setting): string => $setting->isOidc() ? '' : $setting->provider)
->reduce(function ($carry, $setting) {
$carry[$setting->provider] = $this->oauthSettingToArray($setting);
return $carry;
}, []);
if ($this->selectedProvider !== null && ! array_key_exists($this->selectedProvider, $this->oauth_settings_map)) {
abort(404);
}
return null;
}
private function updateOauthSettings(?string $provider = null)
private function updateOauthSettings(?string $provider = null): void
{
$this->validate($this->validationRules($provider));
if ($provider) {
$oauthData = $this->oauth_settings_map[$provider];
$oauth = OauthSetting::find($oauthData['id']);
@ -57,78 +93,128 @@ private function updateOauthSettings(?string $provider = null)
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
$oauth->fill([
'enabled' => $oauthData['enabled'],
'client_id' => $oauthData['client_id'],
'client_secret' => $oauthData['client_secret'],
'redirect_uri' => $oauthData['redirect_uri'],
'tenant' => $oauthData['tenant'],
'base_url' => $oauthData['base_url'],
]);
if ($oauthData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
$this->fillOauthSetting($oauth, $oauthData);
$this->ensureProviderCanBeEnabled($oauth);
$oauth->save();
// Update the array with fresh data
$this->oauth_settings_map[$provider] = [
'id' => $oauth->id,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'client_id' => $oauth->client_id,
'client_secret' => $oauth->client_secret,
'redirect_uri' => $oauth->redirect_uri,
'tenant' => $oauth->tenant,
'base_url' => $oauth->base_url,
];
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
$this->dispatch('success', 'OAuth settings for '.$oauth->provider.' updated successfully!');
} else {
$errors = [];
foreach (array_values($this->oauth_settings_map) as $settingData) {
$oauth = OauthSetting::find($settingData['id']);
if (! $oauth) {
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
continue;
}
$oauth->fill([
'enabled' => $settingData['enabled'],
'client_id' => $settingData['client_id'],
'client_secret' => $settingData['client_secret'],
'redirect_uri' => $settingData['redirect_uri'],
'tenant' => $settingData['tenant'],
'base_url' => $settingData['base_url'],
]);
if ($settingData['enabled'] && ! $oauth->couldBeEnabled()) {
$oauth->enabled = false;
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
}
$oauth->save();
// Update the array with fresh data
$this->oauth_settings_map[$oauth->provider] = [
'id' => $oauth->id,
'provider' => $oauth->provider,
'enabled' => $oauth->enabled,
'client_id' => $oauth->client_id,
'client_secret' => $oauth->client_secret,
'redirect_uri' => $oauth->redirect_uri,
'tenant' => $oauth->tenant,
'base_url' => $oauth->base_url,
];
}
if (! empty($errors)) {
$this->dispatch('error', implode('<br/>', $errors));
}
return;
}
$errors = [];
foreach (array_values($this->oauth_settings_map) as $settingData) {
$oauth = OauthSetting::find($settingData['id']);
if (! $oauth) {
$errors[] = "OAuth setting for provider '{$settingData['provider']}' not found. It may have been deleted.";
continue;
}
$this->fillOauthSetting($oauth, $settingData);
if ($oauth->enabled && ! $oauth->couldBeEnabled()) {
$oauth->enabled = false;
$errors[] = "OAuth settings are incomplete for '{$oauth->provider}'. Required fields are missing. The provider has been disabled.";
}
if ($oauth->enabled && $oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
$oauth->enabled = false;
$errors[] = "OIDC scopes must include 'openid'. The provider has been disabled.";
}
$oauth->save();
$this->oauth_settings_map[$oauth->provider] = $this->oauthSettingToArray($oauth);
}
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
if (! empty($errors)) {
$this->dispatch('error', implode('<br/>', $errors));
}
}
private function fillOauthSetting(OauthSetting $oauth, array $data): void
{
$oauth->fill([
'enabled' => (bool) ($data['enabled'] ?? false),
'client_id' => $data['client_id'] ?? null,
'client_secret' => $data['client_secret'] ?? null,
'redirect_uri' => $this->nullableString($data['redirect_uri'] ?? null),
'tenant' => $data['tenant'] ?? null,
'base_url' => $this->nullableString($data['base_url'] ?? null),
'custom_label' => $data['custom_label'] ?? null,
'scopes' => $data['scopes'] ?? null,
'allow_registration' => (bool) ($data['allow_registration'] ?? false),
'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false),
'require_email_verified' => (bool) ($data['require_email_verified'] ?? true),
'use_pkce' => (bool) ($data['use_pkce'] ?? true),
'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60),
]);
}
private function nullableString(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function ensureProviderCanBeEnabled(OauthSetting $oauth): void
{
if (! $oauth->enabled) {
return;
}
if (! $oauth->couldBeEnabled()) {
$oauth->update(['enabled' => false]);
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
$oauth->update(['enabled' => false]);
throw new \Exception("OIDC scopes must include 'openid'.");
}
}
private function oauthSettingToArray(OauthSetting $setting): array
{
return [
'id' => $setting->id,
'provider' => $setting->provider,
'enabled' => $setting->enabled,
'client_id' => $setting->client_id,
'client_secret' => $setting->client_secret,
'redirect_uri' => $setting->redirect_uri,
'tenant' => $setting->tenant,
'base_url' => $setting->base_url,
'custom_label' => $setting->custom_label,
'scopes' => $setting->scopes ?: 'openid email profile',
'allow_registration' => $setting->allow_registration,
'auto_join_root_team' => $setting->auto_join_root_team,
'require_email_verified' => $setting->require_email_verified ?? true,
'use_pkce' => $setting->use_pkce ?? true,
'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,
'label' => $this->providerLabel($setting->provider),
];
}
public function providerLabel(string $provider): string
{
return match ($provider) {
'oidc' => 'OpenID Connect',
'gitlab' => 'GitLab',
default => str($provider)->headline()->toString(),
};
}
public function instantSave(string $provider)
@ -141,56 +227,88 @@ public function instantSave(string $provider)
}
}
public function toggleProvider(string $provider): mixed
public function toggleProvider(string $provider)
{
try {
$this->authorize('update', instanceSettings());
if (! array_key_exists($provider, $this->oauth_settings_map)) {
throw new \Exception('OAuth provider not found.');
abort(404);
}
$enabling = ! $this->oauth_settings_map[$provider]['enabled'];
if ($enabling) {
$this->validate($this->providerRules($provider));
if (! (bool) $this->oauth_settings_map[$provider]['enabled']) {
$this->validateProviderCanBeEnabled($provider);
}
$this->oauth_settings_map[$provider]['enabled'] = $enabling;
$this->oauth_settings_map[$provider]['enabled'] = ! (bool) $this->oauth_settings_map[$provider]['enabled'];
$this->updateOauthSettings($provider);
} catch (\Throwable $e) {
} catch (\Exception $e) {
$oauth = OauthSetting::where('provider', $provider)->first();
if ($oauth) {
$this->oauth_settings_map[$provider] = $this->oauthSettingToArray($oauth);
}
return handleError($e, $this);
}
return null;
}
private function providerRules(string $provider): array
private function validateProviderCanBeEnabled(string $provider): void
{
$prefix = "oauth_settings_map.$provider";
$rules = [
"$prefix.client_id" => 'required',
"$prefix.client_secret" => 'required',
];
$this->validate($this->validationRules($provider));
if ($provider === 'azure') {
$rules["$prefix.tenant"] = 'required';
$oauth = OauthSetting::find($this->oauth_settings_map[$provider]['id']);
if (! $oauth) {
throw new \Exception('OAuth setting for '.$provider.' not found. It may have been deleted.');
}
if (in_array($provider, ['authentik', 'clerk'], true)) {
$rules["$prefix.base_url"] = 'required';
$this->fillOauthSetting($oauth, [
...$this->oauth_settings_map[$provider],
'enabled' => true,
]);
if (! $oauth->couldBeEnabled()) {
throw new \Exception('OAuth settings are not complete for '.$oauth->provider.'.<br/>Please fill in all required fields.');
}
return $rules;
if ($oauth->isOidc() && ! in_array('openid', $oauth->scopeList(), true)) {
throw new \Exception("OIDC scopes must include 'openid'.");
}
}
public function submit()
public function saveRegistrationPolicy(): void
{
$this->authorize('update', instanceSettings());
$this->validate([
'disable_registration_when_oauth_enabled' => 'boolean',
]);
instanceSettings()->update([
'disable_registration_when_oauth_enabled' => $this->disable_registration_when_oauth_enabled,
]);
$this->dispatch('success', 'Authentication settings updated successfully!');
}
public function submit(): void
{
try {
$this->authorize('update', instanceSettings());
$this->updateOauthSettings();
$this->dispatch('success', 'Instance settings updated successfully!');
} catch (\Throwable $e) {
return handleError($e, $this);
$this->updateOauthSettings($this->selectedProvider);
if ($this->selectedProvider === null) {
$this->dispatch('success', 'Instance settings updated successfully!');
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
if ($this->selectedProvider !== null) {
$oauth = OauthSetting::where('provider', $this->selectedProvider)->first();
if ($oauth) {
$this->oauth_settings_map[$this->selectedProvider] = $this->oauthSettingToArray($oauth);
}
}
handleError($e, $this);
}
}
}

View file

@ -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',
@ -82,6 +83,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',
@ -108,6 +111,19 @@ protected static function booted(): void
});
}
public function isPasswordRegistrationAllowed(): bool
{
if (! $this->is_registration_enabled) {
return false;
}
if (! $this->disable_registration_when_oauth_enabled) {
return true;
}
return ! OauthSetting::where('enabled', true)->exists();
}
public function fqdn(): Attribute
{
return Attribute::make(

View file

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

View file

@ -11,7 +11,19 @@ class OauthSetting extends Model
{
use HasFactory;
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled'];
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'allow_registration' => 'boolean',
'auto_join_root_team' => 'boolean',
'require_email_verified' => 'boolean',
'use_pkce' => 'boolean',
'clock_skew_seconds' => 'integer',
];
}
protected $hidden = [
'client_secret',
@ -32,9 +44,46 @@ public function couldBeEnabled(): bool
return filled($this->client_id) && filled($this->client_secret) && filled($this->tenant);
case 'authentik':
case 'clerk':
case 'oidc':
return filled($this->client_id) && filled($this->client_secret) && filled($this->base_url);
default:
return filled($this->client_id) && filled($this->client_secret);
}
}
/**
* @return array<int, string>
*/
public function scopeList(): array
{
$scopes = str($this->scopes ?: 'openid email profile')
->replace(',', ' ')
->explode(' ')
->map(fn (string $scope) => trim($scope))
->filter()
->unique()
->values()
->all();
return $scopes === [] ? ['openid', 'email', 'profile'] : $scopes;
}
public function loginLabel(): string
{
if (filled($this->custom_label)) {
return $this->custom_label;
}
$envLabel = config("services.{$this->provider}.custom_label");
if (filled($envLabel)) {
return $envLabel;
}
return __("auth.login.{$this->provider}");
}
public function isOidc(): bool
{
return $this->provider === 'oidc';
}
}

View file

@ -11,6 +11,7 @@
use App\Traits\DeletesUserSessions;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notifiable;
@ -507,6 +508,16 @@ public function hasEmailChangeRequest(): bool
&& Carbon::now()->lessThan($this->email_change_code_expires_at);
}
public function oauthIdentities(): HasMany
{
return $this->hasMany(OauthIdentity::class);
}
public function hasSsoIdentity(): bool
{
return $this->oauthIdentities()->exists();
}
/**
* Check if the user has a password set.
* OAuth users are created without passwords.

View file

@ -2,6 +2,9 @@
namespace App\Providers;
use App\Auth\Oidc\OidcDiscoveryService;
use App\Auth\Oidc\OidcTokenValidator;
use App\Auth\Oidc\Socialite\OidcProvider;
use App\Models\PersonalAccessToken;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\App;
@ -10,6 +13,7 @@
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\Sanctum;
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
@ -22,12 +26,11 @@ public function register(): void
public function boot(): void
{
$this->configureCommands();
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
$this->configureGitHubHttp();
$this->configureOidcSocialite();
}
private function configureCommands(): void
@ -62,6 +65,24 @@ private function configureSanctumModel(): void
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);
}
private function configureOidcSocialite(): void
{
if (! $this->app->bound(SocialiteFactory::class)) {
return;
}
$this->app->make(SocialiteFactory::class)->extend('oidc', function ($app) {
return new OidcProvider(
$app['request'],
$app->make(OidcDiscoveryService::class),
$app->make(OidcTokenValidator::class),
'',
'',
'',
);
});
}
private function configureGitHubHttp(): void
{
Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) {
@ -77,16 +98,5 @@ private function configureGitHubHttp(): void
])->baseUrl($api_url);
}
});
Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
$client = Http::withHeaders([
'Accept' => 'application/json',
])->baseUrl($api_url);
if ($access_token) {
$client = $client->withToken($access_token);
}
return $client;
});
}
}

View file

@ -48,7 +48,7 @@ public function boot(): void
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
if (! $settings->isPasswordRegistrationAllowed()) {
return redirect()->route('login');
}
@ -61,13 +61,13 @@ public function boot(): void
$settings = instanceSettings();
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// If there are no users, redirect to registration
if ($users == 0 && $settings->isPasswordRegistrationAllowed()) {
// If there are no users and password registration is allowed, redirect to registration.
return redirect()->route('register');
}
return view('auth.login', [
'is_registration_enabled' => $settings->is_registration_enabled,
'is_registration_enabled' => $settings->isPasswordRegistrationAllowed(),
'enabled_oauth_providers' => $enabled_oauth_providers,
]);
});

View file

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

View file

@ -1,7 +1,13 @@
<?php
use App\Auth\Oidc\OidcConfig;
use App\Models\OauthSetting;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\BitbucketProvider;
use Laravel\Socialite\Two\GithubProvider;
use Laravel\Socialite\Two\GitlabProvider;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\Config;
function get_socialite_provider(string $provider)
{
@ -12,7 +18,7 @@ function get_socialite_provider(string $provider)
}
if ($provider === 'azure') {
$azure_config = new \SocialiteProviders\Manager\Config(
$azure_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
@ -23,7 +29,7 @@ function get_socialite_provider(string $provider)
}
if ($provider == 'authentik' || $provider == 'clerk') {
$authentik_clerk_config = new \SocialiteProviders\Manager\Config(
$authentik_clerk_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
@ -34,7 +40,7 @@ function get_socialite_provider(string $provider)
}
if ($provider == 'zitadel') {
$zitadel_config = new \SocialiteProviders\Manager\Config(
$zitadel_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri,
@ -44,8 +50,12 @@ function get_socialite_provider(string $provider)
return Socialite::driver('zitadel')->setConfig($zitadel_config);
}
if ($provider === 'oidc') {
return Socialite::driver('oidc')->setConfig(OidcConfig::fromOauthSetting($oauth_setting));
}
if ($provider == 'google') {
$google_config = new \SocialiteProviders\Manager\Config(
$google_config = new Config(
$oauth_setting->client_id,
$oauth_setting->client_secret,
$oauth_setting->redirect_uri
@ -63,11 +73,11 @@ function get_socialite_provider(string $provider)
];
$provider_class_map = [
'bitbucket' => \Laravel\Socialite\Two\BitbucketProvider::class,
'discord' => \SocialiteProviders\Discord\Provider::class,
'github' => \Laravel\Socialite\Two\GithubProvider::class,
'gitlab' => \Laravel\Socialite\Two\GitlabProvider::class,
'infomaniak' => \SocialiteProviders\Infomaniak\Provider::class,
'bitbucket' => BitbucketProvider::class,
'discord' => Provider::class,
'github' => GithubProvider::class,
'gitlab' => GitlabProvider::class,
'infomaniak' => SocialiteProviders\Infomaniak\Provider::class,
];
$socialite = Socialite::buildProvider(

View file

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

2
composer.lock generated
View file

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

View file

@ -60,6 +60,14 @@
'tenant' => env('GOOGLE_TENANT'),
],
'oidc' => [
'client_id' => env('OIDC_CLIENT_ID'),
'client_secret' => env('OIDC_CLIENT_SECRET'),
'redirect' => env('OIDC_REDIRECT_URI'),
'base_url' => env('OIDC_BASE_URL'),
'custom_label' => env('OIDC_LOGIN_LABEL'),
],
'zitadel' => [
'client_id' => env('ZITADEL_CLIENT_ID'),
'client_secret' => env('ZITADEL_CLIENT_SECRET'),

View file

@ -8,6 +8,12 @@
/**
* The configuration snapshot/diff now store an encrypted blob (not valid
* JSON), so the columns must hold arbitrary text instead of json.
*
* Coolify's own backend runs exclusively on PostgreSQL in production and
* SQLite in testing (see config/database.php the only configured
* connections are `pgsql` and `testing`). MySQL/MariaDB are user-managed
* resources, never Coolify's application database, so no driver path is
* needed for them here.
*/
public function up(): void
{

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->string('custom_label')->nullable();
$table->string('scopes')->nullable();
$table->boolean('allow_registration')->default(true);
$table->boolean('require_email_verified')->default(true);
$table->boolean('use_pkce')->default(true);
$table->unsignedSmallInteger('clock_skew_seconds')->default(60);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->dropColumn([
'custom_label',
'scopes',
'allow_registration',
'require_email_verified',
'use_pkce',
'clock_skew_seconds',
]);
});
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_identities', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('provider');
$table->string('issuer');
$table->string('provider_user_id');
$table->string('email')->nullable()->index();
$table->json('raw_claims')->nullable();
$table->timestamp('last_login_at')->nullable();
$table->timestamps();
$table->unique(['provider', 'issuer', 'provider_user_id'], 'oauth_identity_provider_issuer_user_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_identities');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('disable_registration_when_oauth_enabled')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('disable_registration_when_oauth_enabled');
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->boolean('auto_join_root_team')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->dropColumn('auto_join_root_team');
});
}
};

View file

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

View file

@ -15,12 +15,10 @@ public function run(): void
'email' => 'test@example.com',
]);
User::factory()->create([
'id' => 1,
'name' => 'Normal User (but in root team)',
'email' => 'test2@example.com',
]);
User::factory()->create([
'id' => 2,
'name' => 'Normal User (not in root team)',
'email' => 'test3@example.com',
]);

View file

@ -7,6 +7,7 @@
"auth.login.github": "Mit GitHub anmelden",
"auth.login.gitlab": "Mit GitLab anmelden",
"auth.login.google": "Mit Google anmelden",
"auth.login.oidc": "Mit SSO anmelden",
"auth.login.infomaniak": "Mit Infomaniak anmelden",
"auth.login.zitadel": "Mit Zitadel anmelden",
"auth.already_registered": "Bereits registriert?",

View file

@ -8,6 +8,7 @@
"auth.login.github": "Login with GitHub",
"auth.login.gitlab": "Login with Gitlab",
"auth.login.google": "Login with Google",
"auth.login.oidc": "Login with SSO",
"auth.login.infomaniak": "Login with Infomaniak",
"auth.login.zitadel": "Login with Zitadel",
"auth.already_registered": "Already registered?",

View file

@ -8,6 +8,7 @@
"auth.login.github": "Zaloguj się przez GitHub",
"auth.login.gitlab": "Zaloguj się przez Gitlab",
"auth.login.google": "Zaloguj się przez Google",
"auth.login.oidc": "Zaloguj się przez SSO",
"auth.login.infomaniak": "Zaloguj się przez Infomaniak",
"auth.login.zitadel": "Zaloguj się przez Zitadel",
"auth.already_registered": "Już zarejestrowany?",

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

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

After

Width:  |  Height:  |  Size: 383 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -164,6 +164,9 @@
Route::get('/settings/backup', SettingsBackup::class)->name('settings.backup');
Route::get('/settings/email', SettingsEmail::class)->name('settings.email');
Route::get('/settings/oauth', SettingsOauth::class)->name('settings.oauth');
Route::get('/settings/oauth/{provider}', SettingsOauth::class)
->where('provider', '[A-Za-z0-9_-]+')
->name('settings.oauth.provider');
Route::get('/settings/scheduled-jobs', SettingsScheduledJobs::class)->name('settings.scheduled-jobs');
Route::get('/profile', ProfileIndex::class)->name('profile');

View file

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

View file

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

View file

@ -0,0 +1,179 @@
<?php
use App\Livewire\Notifications\Discord;
use App\Livewire\Notifications\Email;
use App\Livewire\Notifications\Pushover;
use App\Livewire\Notifications\Slack;
use App\Livewire\Notifications\Telegram;
use App\Livewire\Notifications\Webhook;
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function actingAsEnableActionOwner(): array
{
$team = Team::factory()->create();
$user = User::factory()->create(['email' => 'owner@example.com']);
$user->teams()->attach($team, ['role' => 'owner']);
session(['currentTeam' => $team]);
test()->actingAs($user);
return [$user, $team];
}
function actingAsEnableActionInstanceAdmin(): User
{
$team = Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
$user = User::factory()->create(['id' => 0, 'email' => 'root-enable-actions@example.com']);
if (! $user->teams()->whereKey($team->id)->exists()) {
$user->teams()->attach($team, ['role' => 'owner']);
}
session(['currentTeam' => $team]);
test()->actingAs($user);
return $user;
}
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Once::flush();
});
it('renders settings email enable actions instead of enabled checkboxes', function () {
$view = file_get_contents(resource_path('views/livewire/settings-email.blade.php'));
expect($view)->toContain('Enable SMTP Server')
->and($view)->toContain('Disable SMTP Server')
->and($view)->toContain('Enable Resend')
->and($view)->toContain('Disable Resend')
->and($view)->not->toContain('id="smtpEnabled" label="Enabled"')
->and($view)->not->toContain('id="resendEnabled" label="Enabled"');
});
it('keeps transactional smtp disabled when enable validation fails', function () {
actingAsEnableActionInstanceAdmin();
Livewire::test(SettingsEmail::class)
->call('toggleSmtp')
->assertDispatched('error')
->assertSet('smtpEnabled', false);
expect(instanceSettings()->fresh()->smtp_enabled)->toBeFalse();
});
it('enables transactional smtp only after required fields validate', function () {
actingAsEnableActionInstanceAdmin();
Livewire::test(SettingsEmail::class)
->set('smtpFromAddress', 'mail@example.com')
->set('smtpFromName', 'Coolify')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->call('toggleSmtp')
->assertHasNoErrors()
->assertSet('smtpEnabled', true)
->assertSet('resendEnabled', false);
expect(instanceSettings()->fresh()->smtp_enabled)->toBeTrue()
->and(instanceSettings()->fresh()->resend_enabled)->toBeFalse();
});
it('renders notification provider enable actions instead of enabled checkboxes', function (string $view, string $enableLabel, string $checkboxSnippet) {
$contents = file_get_contents(resource_path("views/livewire/notifications/{$view}.blade.php"));
expect($contents)->toContain($enableLabel)
->and($contents)->not->toContain($checkboxSnippet);
})->with([
'discord' => ['discord', 'Enable Discord', 'id="discordEnabled" label="Enabled"'],
'slack' => ['slack', 'Enable Slack', 'id="slackEnabled" label="Enabled"'],
'telegram' => ['telegram', 'Enable Telegram', 'id="telegramEnabled" label="Enabled"'],
'pushover' => ['pushover', 'Enable Pushover', 'id="pushoverEnabled" label="Enabled"'],
'webhook' => ['webhook', 'Enable Webhook', 'id="webhookEnabled" label="Enabled"'],
]);
it('shows notification provider save buttons while disabled', function (string $component) {
actingAsEnableActionOwner();
Livewire::test($component)
->assertSet(str(class_basename($component))->camel()->append('Enabled')->toString(), false)
->assertSee('Save');
})->with([
'discord' => [Discord::class],
'slack' => [Slack::class],
'telegram' => [Telegram::class],
'pushover' => [Pushover::class],
'webhook' => [Webhook::class],
]);
it('hides notification provider test buttons while disabled and shows them when enabled', function (string $component, string $enabledProperty) {
actingAsEnableActionOwner();
Livewire::test($component)
->assertDontSee('Send Test Notification');
Livewire::test($component)
->set($enabledProperty, true)
->assertSee('Send Test Notification');
})->with([
'discord' => [Discord::class, 'discordEnabled'],
'slack' => [Slack::class, 'slackEnabled'],
'telegram' => [Telegram::class, 'telegramEnabled'],
'pushover' => [Pushover::class, 'pushoverEnabled'],
'webhook' => [Webhook::class, 'webhookEnabled'],
]);
it('hides the email test button while email notifications are disabled', function () {
actingAsEnableActionOwner();
Livewire::test(Email::class)
->assertDontSee('Send Test Email');
});
it('keeps notification providers disabled when enable validation fails', function (string $component, string $method, string $enabledProperty, string $requiredField, string $settingsRelation, string $settingsColumn) {
[, $team] = actingAsEnableActionOwner();
Livewire::test($component)
->call($method)
->assertDispatched('error')
->assertSet($enabledProperty, false);
expect($team->{$settingsRelation}->fresh()->{$settingsColumn})->toBeFalse();
})->with([
'discord' => [Discord::class, 'toggleDiscordEnabled', 'discordEnabled', 'discordWebhookUrl', 'discordNotificationSettings', 'discord_enabled'],
'slack' => [Slack::class, 'toggleSlackEnabled', 'slackEnabled', 'slackWebhookUrl', 'slackNotificationSettings', 'slack_enabled'],
'telegram' => [Telegram::class, 'toggleTelegramEnabled', 'telegramEnabled', 'telegramToken', 'telegramNotificationSettings', 'telegram_enabled'],
'pushover' => [Pushover::class, 'togglePushoverEnabled', 'pushoverEnabled', 'pushoverUserKey', 'pushoverNotificationSettings', 'pushover_enabled'],
'webhook' => [Webhook::class, 'toggleWebhookEnabled', 'webhookEnabled', 'webhookUrl', 'webhookNotificationSettings', 'webhook_enabled'],
]);
it('renders notification email and log drain enable actions instead of enabled checkboxes', function () {
$notificationEmail = file_get_contents(resource_path('views/livewire/notifications/email.blade.php'));
$logDrains = file_get_contents(resource_path('views/livewire/server/log-drains.blade.php'));
expect($notificationEmail)->toContain('Enable SMTP Server')
->and($notificationEmail)->toContain('Enable Resend')
->and($notificationEmail)->not->toContain('id="smtpEnabled"')
->and($notificationEmail)->not->toContain('id="resendEnabled"')
->and($logDrains)->toContain('Enable New Relic')
->and($logDrains)->toContain('Enable Axiom')
->and($logDrains)->toContain('Enable Custom FluentBit')
->and($logDrains)->not->toContain('label="Enabled"');
});
it('keeps notification email smtp disabled when enable validation fails', function () {
actingAsEnableActionOwner();
Livewire::test(Email::class)
->call('toggleSmtp')
->assertDispatched('error')
->assertSet('smtpEnabled', false);
});

View file

@ -0,0 +1,45 @@
<?php
use App\Actions\Server\StartLogDrain;
use App\Livewire\Server\LogDrains;
use App\Models\Server;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = $this->user->teams()->first();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
it('reverts the persisted enabled flag when starting the log drain fails', function () {
StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom'));
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy();
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainNewRelicLicenseKey', 'abc123')
->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
->call('toggleLogDrain', 'newrelic')
->assertSet('isLogDrainNewRelicEnabled', false);
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeFalsy();
});
it('keeps the enabled flag persisted when starting the log drain succeeds', function () {
StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok');
Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid])
->set('logDrainNewRelicLicenseKey', 'abc123')
->set('logDrainNewRelicBaseUri', 'https://log-api.newrelic.com')
->call('toggleLogDrain', 'newrelic')
->assertSet('isLogDrainNewRelicEnabled', true);
expect($this->server->settings->fresh()->is_logdrain_newrelic_enabled)->toBeTruthy();
});

View file

@ -37,6 +37,28 @@
->not->toMatch('/\.auth-shell\s*\{[^}]*color-mix\(in oklab, var\(--color-accent\) 9%, transparent\)/s');
});
test('external login providers are centered and full width', function () {
$login = file_get_contents(resource_path('views/auth/login.blade.php'));
expect($login)
->toContain('class="flex flex-col gap-2"')
->toContain('class="w-full justify-center"')
->not->toContain('sm:w-[calc(50%-0.25rem)]');
});
test('external login providers display their icons except oidc', function () {
$login = file_get_contents(resource_path('views/auth/login.blade.php'));
expect($login)
->toContain("@if (\$provider_setting->provider !== 'oidc')")
->toContain("asset('svgs/'.\$provider_setting->provider.'.svg')")
->toContain('class="size-5 shrink-0 dark:invert"');
foreach (['authentik', 'azure', 'bitbucket', 'clerk', 'discord', 'github', 'gitlab', 'google', 'infomaniak', 'zitadel'] as $provider) {
expect(public_path("svgs/{$provider}.svg"))->toBeFile();
}
});
test('error pages use the Coollabs purple background glow', function () {
$styles = file_get_contents(resource_path('css/app.css'));

View file

@ -4,22 +4,26 @@
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,
]);
});
@ -76,4 +80,6 @@
})->with([
'null email' => [null],
'blank email' => [' '],
'malformed email' => ['not-an-email'],
'missing domain' => ['user@'],
]);

View file

@ -0,0 +1,52 @@
<?php
use App\Actions\Fortify\CreateNewUser;
use App\Models\InstanceSettings;
use App\Models\OauthSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Once;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate([
'id' => 0,
'is_registration_enabled' => true,
'disable_registration_when_oauth_enabled' => true,
]);
Once::flush();
});
it('blocks password registration when oauth registration policy disables it', function () {
OauthSetting::create([
'provider' => 'oidc',
'enabled' => true,
'client_id' => 'client-id',
'client_secret' => 'secret',
'base_url' => 'https://idp.example.com',
]);
app(CreateNewUser::class)->create([
'name' => 'Password User',
'email' => 'password@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
})->throws(HttpException::class);
it('allows password registration when no oauth provider is enabled', function () {
OauthSetting::create([
'provider' => 'oidc',
'enabled' => false,
]);
$user = app(CreateNewUser::class)->create([
'name' => 'Password User',
'email' => 'password@example.com',
'password' => 'password',
'password_confirmation' => 'password',
]);
expect($user->email)->toBe('password@example.com');
});

View file

@ -0,0 +1,233 @@
<?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('creates a new oidc user in the root team only when provider root auto-join is enabled', function () {
Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
(new User)->forceFill([
'id' => 0,
'name' => 'Root User',
'email' => 'root@example.com',
'password' => 'password',
])->save();
OauthSetting::where('provider', 'oidc')->update([
'allow_registration' => true,
'auto_join_root_team' => true,
]);
fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$user = User::whereEmail('root-member@example.com')->first();
expect($user)->not->toBeNull()
->and($user->teams()->count())->toBe(1);
$rootMembership = $user->teams()->where('teams.id', 0)->first();
expect($rootMembership)->not->toBeNull()
->and($rootMembership->pivot->role)->toBe('member');
$this->assertDatabaseMissing('teams', [
'name' => "Root Member's Team",
]);
expect(session('currentTeam')->id)->toBe(0);
$this->assertAuthenticatedAs($user);
});
it('rejects linking an unverified oidc email to an existing local account', function () {
$user = User::factory()->create(['email' => 'victim@example.com']);
fakeOidcProvider(['email' => 'victim@example.com', 'email_verified' => false]);
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/login');
$this->assertGuest();
$this->assertDatabaseMissing('oauth_identities', [
'user_id' => $user->id,
'provider' => 'oidc',
]);
});
it('rejects new oidc users when neither normal nor provider registration is enabled', function () {
fakeOidcProvider(['email' => 'blocked@example.com']);
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/login');
expect(User::whereEmail('blocked@example.com')->exists())->toBeFalse();
});
it('creates the root user when oidc provisions the first account', function () {
Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
fakeOidcProvider(['email' => 'root@example.com', 'name' => 'Root User']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$this->assertDatabaseHas('users', ['id' => 0, 'email' => 'root@example.com']);
$this->assertDatabaseHas('team_user', ['team_id' => 0, 'user_id' => 0, 'role' => 'owner']);
expect(InstanceSettings::find(0)->is_registration_enabled)->toBeFalse();
});
it('persists raw claims as an array on the oauth identity', function () {
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
fakeOidcProvider(['email' => 'claims@example.com']);
$this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
$identity = OauthIdentity::where('email', 'claims@example.com')->first();
expect($identity->raw_claims)->toBeArray()
->and($identity->raw_claims['sub'])->toBe('okta-user-1');
});
it('stores empty raw claims when the provider returns no user payload', function () {
OauthSetting::where('provider', 'oidc')->update(['allow_registration' => true]);
$user = (new OidcUser)->setIdTokenClaims([
'iss' => 'https://idp.example.com',
'sub' => 'okta-no-payload',
'email_verified' => true,
])->map([
'id' => 'okta-no-payload',
'name' => 'No Payload',
'email' => 'nopayload@example.com',
]);
$user->user = null;
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('user')->andReturn($user);
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
$this->get(route('auth.callback', 'oidc'))->assertRedirect('/');
$identity = OauthIdentity::where('email', 'nopayload@example.com')->first();
expect($identity->raw_claims)->toBe([]);
});
it('rejects callbacks for disabled oidc provider', function () {
OauthSetting::where('provider', 'oidc')->update(['enabled' => false]);
$response = $this->from('/login')->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/login');
});
it('logs callback failures with diagnostic context', function () {
Log::spy();
$provider = Mockery::mock();
$provider->shouldReceive('setConfig')->andReturnSelf();
$provider->shouldReceive('user')->andThrow(new RuntimeException('Token exchange failed'));
Socialite::shouldReceive('driver')->with('oidc')->andReturn($provider);
$response = $this->from('/login')->get(route('auth.callback', ['provider' => 'oidc', 'code' => 'secret-code', 'state' => 'state-value']));
$response->assertRedirect('/login');
Log::shouldHaveReceived('error')->once()->withArgs(function (string $message, array $context) {
return $message === 'OAuth callback failed.'
&& $context['provider'] === 'oidc'
&& $context['exception_class'] === RuntimeException::class
&& $context['exception_message'] === 'Token exchange failed'
&& $context['has_code'] === true
&& $context['has_state'] === true
&& $context['exception'] instanceof RuntimeException;
});
});

View file

@ -0,0 +1,91 @@
<?php
use App\Livewire\Profile\Index as ProfileIndex;
use App\Models\OauthIdentity;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('shows when the profile user signed in with sso', function () {
$user = User::factory()->create(['name' => 'Profile User']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'idp-user-1',
'email' => $user->email,
]);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->assertSee('Signed in with SSO')
->assertSee('OIDC');
});
it('does not show sso status for password-only profile users', function () {
$user = User::factory()->create(['name' => 'Profile User']);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->assertDontSee('Signed in with SSO');
});
it('prevents sso linked users from opening or requesting profile email changes', function () {
$user = User::factory()->create(['name' => 'SSO User', 'email' => 'sso@example.com']);
OauthIdentity::create([
'user_id' => $user->id,
'provider' => 'oidc',
'issuer' => 'https://idp.example.com',
'provider_user_id' => 'idp-user-1',
'email' => $user->email,
]);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->assertSee('Email is managed by your SSO provider.')
->call('showEmailChangeForm')
->assertSet('show_email_change', false)
->assertDispatched('error')
->set('new_email', 'changed@example.com')
->call('requestEmailChange')
->assertSet('show_email_change', false)
->assertSet('show_verification', false)
->assertDispatched('error');
$user->refresh();
expect($user->email)->toBe('sso@example.com')
->and($user->pending_email)->toBeNull()
->and($user->email_change_code)->toBeNull()
->and($user->email_change_code_expires_at)->toBeNull();
});
it('keeps profile email changes available for password-only users', function () {
config()->set('constants.coolify.self_hosted', false);
Notification::fake();
$user = User::factory()->create(['name' => 'Password User', 'email' => 'password@example.com']);
$this->actingAs($user);
Livewire::test(ProfileIndex::class)
->call('showEmailChangeForm')
->assertSet('show_email_change', true)
->set('new_email', 'changed@example.com')
->call('requestEmailChange')
->assertSet('show_verification', true)
->assertDispatched('success');
$user->refresh();
expect($user->pending_email)->toBe('changed@example.com')
->and($user->email_change_code)->not->toBeNull();
});

View file

@ -0,0 +1,64 @@
<?php
use App\Livewire\SettingsEmail;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->settings = new InstanceSettings;
$this->settings->id = 0;
$this->settings->save();
$this->rootTeam = Team::factory()->create(['id' => 0]);
$this->user = User::factory()->create();
$this->user->teams()->attach($this->rootTeam, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->rootTeam]);
});
test('enabling SMTP disables Resend in storage', function () {
$this->settings->update([
'resend_enabled' => true,
'resend_api_key' => 're_test_key',
'smtp_from_address' => 'from@example.com',
'smtp_from_name' => 'Coolify',
]);
Livewire::test(SettingsEmail::class)
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpFromAddress', 'from@example.com')
->set('smtpFromName', 'Coolify')
->call('toggleSmtp');
$this->settings->refresh();
expect($this->settings->smtp_enabled)->toBeTrue();
expect($this->settings->resend_enabled)->toBeFalse();
});
test('enabling Resend disables SMTP in storage', function () {
$this->settings->update([
'smtp_enabled' => true,
'smtp_host' => 'smtp.example.com',
'smtp_port' => '587',
'smtp_encryption' => 'starttls',
'smtp_from_address' => 'from@example.com',
'smtp_from_name' => 'Coolify',
]);
Livewire::test(SettingsEmail::class)
->set('resendApiKey', 're_test_key')
->set('smtpFromAddress', 'from@example.com')
->set('smtpFromName', 'Coolify')
->call('toggleResend');
$this->settings->refresh();
expect($this->settings->resend_enabled)->toBeTrue();
expect($this->settings->smtp_enabled)->toBeFalse();
});

View file

@ -0,0 +1,52 @@
<?php
it('keeps backup and transactional email out of the settings top navigation', function () {
$this->blade('<x-settings.navbar />')
->assertSeeText('Configuration')
->assertSeeText('OAuth')
->assertSeeText('Scheduled Jobs')
->assertDontSeeText('Instance Backup')
->assertDontSeeText('Transactional Email');
});
it('shows backup and transactional email in the settings configuration sidebar', function () {
$view = $this->blade('<x-settings.sidebar activeMenu="backup" />')
->assertSeeTextInOrder([
'General',
'Advanced',
'Instance Backup',
'Transactional Email',
'Updates',
]);
expect((string) $view)
->toContain(route('settings.backup'))
->toContain(route('settings.email'))
->and(substr_count((string) $view, 'menu-item-active'))->toBe(1);
});
it('renders backup and transactional email pages with the settings configuration sidebar', function () {
expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->toContain('<x-settings.sidebar activeMenu="backup" />')
->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
->toContain('<x-settings.sidebar activeMenu="email" />');
});
it('uses the same title and description spacing on backup and transactional email settings pages', function () {
expect(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->not->toContain('class="flex items-center gap-2 pb-2"')
->toContain('<div class="pb-4">Instance backup configuration for Coolify instance.</div>')
->and(file_get_contents(resource_path('views/livewire/settings-email.blade.php')))
->not->toContain('class="flex flex-col gap-2 pb-4"')
->toContain('<div class="pb-4">Instance wide email settings for password resets, invitations, etc.</div>');
});
it('uses instance backup as the backup settings label', function () {
expect(file_get_contents(resource_path('views/components/settings/sidebar.blade.php')))
->toContain('<span class="menu-item-label">Instance Backup</span>')
->not->toContain('<span class="menu-item-label">Backup</span>')
->and(file_get_contents(resource_path('views/livewire/settings-backup.blade.php')))
->toContain('<h2>Instance Backup</h2>')
->toContain('Instance backup configuration for Coolify instance.')
->not->toContain('<h2>Backup</h2>');
});

View file

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

View file

@ -0,0 +1,16 @@
<?php
use App\Models\User;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('leaves the user id sequence ready for new development users', function () {
$this->seed(UserSeeder::class);
$user = User::factory()->create();
expect(User::query()->orderBy('id')->pluck('id')->all())->toBe([0, 1, 2, 3])
->and($user->id)->toBe(3);
});

View file

@ -296,7 +296,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
expect(app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot)->isChanged())->toBeFalse();
});
it('detects environment variable value changes without exposing secret values', function () {
it('detects environment variable value changes for unlocked variables', function () {
$application = snapshotTestApplication();
EnvironmentVariable::create([
'key' => 'API_TOKEN',
@ -315,13 +315,13 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
$change = collect($diff->changes())->firstWhere('label', 'API_TOKEN');
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBe('Changed')
->and($change['old_display_value'])->toBe('••••••••')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('old-secret')->not->toContain('new-secret');
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('old-secret')
->and($change['new_display_value'])->toBe('new-secret')
->and(json_encode($diff->toArray()))->toContain('old-secret')->toContain('new-secret');
});
it('describes added environment variables as set without exposing secret values', function () {
it('describes added unlocked environment variables with their value', function () {
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
@ -342,6 +342,6 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
expect($change)->not->toBeNull()
->and($change['display_summary'])->toBeNull()
->and($change['old_display_value'])->toBe('-')
->and($change['new_display_value'])->toBe('••••••••')
->and(json_encode($diff->toArray()))->not->toContain('new-secret');
->and($change['new_display_value'])->toBe('new-secret')
->and(json_encode($diff->toArray()))->toContain('new-secret');
});

View file

@ -0,0 +1,30 @@
<?php
use App\Models\OauthSetting;
use Tests\TestCase;
uses(TestCase::class);
it('requires issuer url client id and client secret for oidc settings', function () {
$setting = new OauthSetting(['provider' => 'oidc']);
expect($setting->couldBeEnabled())->toBeFalse();
$setting->fill([
'client_id' => 'client-id',
'client_secret' => 'secret',
'base_url' => 'https://idp.example.com',
]);
expect($setting->couldBeEnabled())->toBeTrue();
});
it('returns configured scopes and custom login label', function () {
$setting = new OauthSetting([
'provider' => 'oidc',
'scopes' => 'openid email profile groups',
'custom_label' => 'Login with Okta',
]);
expect($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
->and($setting->loginLabel())->toBe('Login with Okta');
});

View file

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

View file

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

View file

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