app/Services/Sso.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace App\Services;
use App\Models\Setting;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
/**
* OpenID Connect, authorization code flow with PKCE, confidential client.
*
* Signs in people who already have an account; it never creates one. See
* docs/sso.md for why there is no JWT library here.
*/
class Sso
{
public function __construct(protected SafeHttp $safe) {}
public function enabled(): bool
{
return (bool) Setting::get('sso.enabled', false)
&& $this->issuer() && $this->clientId() && $this->clientSecret();
}
public function providerName(): string
{
return Setting::get('sso.provider_name') ?: 'single sign on';
}
public function issuer(): ?string
{
return Setting::get('sso.issuer') ?: null;
}
public function clientId(): ?string
{
return Setting::get('sso.client_id') ?: null;
}
public function clientSecret(): ?string
{
$stored = Setting::get('sso.client_secret') ?: null;
if ($stored === null) {
return null;
}
try {
return Crypt::decryptString($stored);
} catch (DecryptException) {
return $stored; // saved before secrets were encrypted; re-saving the form fixes it
}
}
/**
* The provider's own description of its endpoints. Cached: it changes about
* never, and a slow provider must not make our login page slow.
*
* @return array<string, mixed>
*/
public function discover(?string $issuer = null): array
{
$issuer = rtrim($issuer ?: (string) $this->issuer(), '/');
return Cache::remember('sso.discovery.'.md5($issuer), 3600, function () use ($issuer) {
$response = $this->safe->to($issuer.'/.well-known/openid-configuration')
->get($issuer.'/.well-known/openid-configuration');
if (! $response->successful()) {
throw new \RuntimeException('The provider answered '.$response->status().' on its discovery URL.');
}
$doc = $response->json();
foreach (['issuer', 'authorization_endpoint', 'token_endpoint'] as $key) {
if (! is_array($doc) || ! is_string($doc[$key] ?? null)) {
throw new \RuntimeException("The discovery document has no {$key}.");
}
}
return $doc;
});
}
/**
* @return array{url: string, state: string, nonce: string, verifier: string}
*/
public function begin(string $redirectUri): array
{
$state = Str::random(40);
$nonce = Str::random(40);
$verifier = Str::random(64);
$query = http_build_query([
'response_type' => 'code',
'client_id' => $this->clientId(),
'redirect_uri' => $redirectUri,
'scope' => 'openid email profile',
'state' => $state,
'nonce' => $nonce,
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
]);
return [
'url' => $this->discover()['authorization_endpoint'].'?'.$query,
'state' => $state,
'nonce' => $nonce,
'verifier' => $verifier,
];
}
/**
* Exchanges the code and returns the verified claims.
*
* @return array<string, mixed>
*
* @throws \RuntimeException on anything that does not add up
*/
public function claims(string $code, string $verifier, string $nonce, string $redirectUri): array
{
$discovery = $this->discover();
// Through the same guard as discovery. The document is the provider's
// word, not ours, and a token_endpoint on 169.254.169.254 would post the
// client secret to the cloud metadata service. Demanding the issuer's
// host instead is not an option: Google's token endpoint lives elsewhere.
$response = $this->safe->to($discovery['token_endpoint'])->timeout(8)->asForm()->post($discovery['token_endpoint'], [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri,
'client_id' => $this->clientId(),
'client_secret' => $this->clientSecret(),
'code_verifier' => $verifier,
]);
if (! $response->successful()) {
throw new \RuntimeException('The provider refused the sign in.');
}
$idToken = $response->json('id_token');
if (! is_string($idToken)) {
throw new \RuntimeException('The provider returned no id_token.');
}
return $this->verify($this->payload($idToken), $nonce, $discovery['issuer']);
}
/**
* The claims arrived over the back channel, so the transport already proves
* the issuer and the signature needs no separate check. Everything the token
* says about *who* and *for whom* still does.
*
* @param array<string, mixed> $claims
* @return array<string, mixed>
*/
public function verify(array $claims, string $nonce, string $issuer): array
{
$audience = (array) ($claims['aud'] ?? []);
return match (true) {
rtrim((string) ($claims['iss'] ?? ''), '/') !== rtrim($issuer, '/') => throw new \RuntimeException('The token came from a different issuer.'),
! in_array($this->clientId(), $audience, true) => throw new \RuntimeException('The token was not meant for this application.'),
! hash_equals($nonce, (string) ($claims['nonce'] ?? '')) => throw new \RuntimeException('The token does not answer this sign in.'),
((int) ($claims['exp'] ?? 0)) < time() => throw new \RuntimeException('The token has expired.'),
! filter_var($claims['email'] ?? null, FILTER_VALIDATE_EMAIL) => throw new \RuntimeException('The provider sent no usable email address.'),
// The email decides which account you become, so an unverified one
// would let anyone who can register at the provider claim it.
($claims['email_verified'] ?? false) !== true => throw new \RuntimeException('The provider has not verified that email address.'),
default => $claims,
};
}
/** @return array<string, mixed> */
protected function payload(string $idToken): array
{
$parts = explode('.', $idToken);
if (count($parts) !== 3) {
throw new \RuntimeException('The id_token is malformed.');
}
$decoded = json_decode(base64_decode(strtr($parts[1], '-_', '+/'), true) ?: '', true);
if (! is_array($decoded)) {
throw new \RuntimeException('The id_token carries no readable claims.');
}
return $decoded;
}
}