coolify/app/Auth/Oidc/Socialite/OidcProvider.php

300 lines
9.5 KiB
PHP
Raw Normal View History

<?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}";
}
}