false, 'require_verified_email' => true, 'auto_provision' => false, 'auto_approve' => false, ]; protected function casts(): array { return [ 'provider' => SocialProvider::class, 'enabled' => 'boolean', 'client_secret' => 'encrypted', 'require_verified_email' => 'boolean', 'auto_provision' => 'boolean', 'auto_approve' => 'boolean', ]; } public static function for(SocialProvider $provider): self { return static::query()->firstOrNew(['provider' => $provider->value]); } /** * Every provider, configured or not, in enum order — so the settings * screen always renders the same cards and a provider added to the * enum needs no seeding. * * @return array */ public static function allProviders(): array { $stored = static::query()->get()->keyBy(fn (self $row): string => $row->provider->value); $rows = []; foreach (SocialProvider::cases() as $provider) { $rows[$provider->value] = $stored->get($provider->value) ?? static::for($provider); } return $rows; } /** * The providers a visitor can actually see, as the sign-in buttons and * the Connected accounts screen need them. * * Cached because this is read on every request through Inertia's * shared props, and flushed on save. Only the key and the label — no * secret is ever written to the cache store, which is the mistake * MailConfigApplier and ExternalStorageConfigApplier both make with * their own credentials. * * @return list */ public static function available(): array { /** @var list */ return Cache::rememberForever(self::AVAILABLE_CACHE_KEY, function (): array { $available = []; foreach (self::allProviders() as $key => $settings) { if ($settings->usable()) { $available[] = ['provider' => $key, 'label' => $settings->provider->label()]; } } return $available; }); } protected static function booted(): void { // Any write to this table can change the button row, including one // that turns a provider off. static::saved(fn () => Cache::forget(self::AVAILABLE_CACHE_KEY)); static::deleted(fn () => Cache::forget(self::AVAILABLE_CACHE_KEY)); } /** * Whether a sign-in may use this provider at all. * * Incomplete configuration behaves exactly as "switched off" rather * than throwing halfway through a redirect — the same rule * LdapSettings::usable() follows, and for the same reason: an * administrator can save a half-filled form. */ public function usable(): bool { if (! $this->enabled) { return false; } if (! $this->filled('client_id') || ! $this->filled('client_secret')) { return false; } if ($this->provider->needsIssuerUrl() && ! $this->filled('issuer_url')) { return false; } if ($this->provider->needsTenantId() && ! $this->filled('tenant_id')) { return false; } return true; } /** * The issuer whose discovery document describes this provider. * * Microsoft's is derived from the tenant, which is what makes the * tenant a security control: the login endpoint we talk to is the * tenant's own, so a token minted by any other tenant cannot arrive * here at all. */ public function issuer(): ?string { return match ($this->provider) { SocialProvider::Microsoft => $this->filled('tenant_id') ? 'https://login.microsoftonline.com/'.$this->tenant_id.'/v2.0' : null, SocialProvider::Oidc => $this->filled('issuer_url') ? rtrim((string) $this->issuer_url, '/') : null, default => null, }; } /** * Whether an address is inside this provider's allow-list. * * Blank means any, which is the default. A public Google client with * auto-provisioning and no list here means the whole internet can * create an account — v1 had no equivalent and no way to say * otherwise. */ public function allowsDomain(string $email): bool { $domains = $this->allowedDomains(); if ($domains === []) { return true; } $at = strrpos($email, '@'); if ($at === false) { return false; } return in_array(Str::lower(substr($email, $at + 1)), $domains, true); } /** * @return list */ public function allowedDomains(): array { $domains = array_map( fn (string $domain): string => Str::lower(trim(ltrim(trim($domain), '@'))), explode(',', (string) $this->allowed_domains), ); return array_values(array_filter($domains, fn (string $domain): bool => $domain !== '')); } private function filled(string $attribute): bool { $value = $this->getAttribute($attribute); return is_string($value) && trim($value) !== ''; } }