coolify/app/Auth/Oidc/OidcTokenValidator.php
Andras Bacsai e354840e5a feat(auth): add OIDC SSO and registration controls
Add OIDC discovery, JWKS validation, Socialite integration, OAuth identity linking, and configurable registration policy for password and SSO signups. Update related settings/profile UI and tests.
2026-06-04 13:59:08 +02:00

142 lines
4.4 KiB
PHP

<?php
namespace App\Auth\Oidc;
use App\Auth\Oidc\Exceptions\OidcTokenException;
class OidcTokenValidator
{
/**
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
public function validate(
string $idToken,
OidcDiscoveryDocument $discovery,
array $jwks,
string $clientId,
?string $expectedNonce = null,
int $clockSkewSeconds = 60,
): array {
[$header, $claims, $signatureInput, $signature] = $this->parse($idToken);
$algorithm = $header['alg'] ?? null;
if ($algorithm !== 'RS256') {
throw new OidcTokenException('id_token uses a disallowed algorithm.');
}
$kid = $header['kid'] ?? null;
if (! is_string($kid) || $kid === '') {
throw new OidcTokenException('id_token header is missing kid.');
}
$jwk = $this->findJwk($jwks, $kid);
$publicKey = RsaJwk::toPem($jwk);
if (openssl_verify($signatureInput, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
throw new OidcTokenException('id_token signature is invalid.');
}
$this->assertIssuer($claims, $discovery->issuer);
$this->assertAudience($claims, $clientId);
$this->assertTimestamps($claims, $clockSkewSeconds);
$this->assertNonce($claims, $expectedNonce);
return $claims;
}
/**
* @return array{0: array<string, mixed>, 1: array<string, mixed>, 2: string, 3: string}
*/
private function parse(string $idToken): array
{
$segments = explode('.', $idToken);
if (count($segments) !== 3) {
throw new OidcTokenException('Malformed id_token.');
}
$header = json_decode(RsaJwk::base64UrlDecode($segments[0]), true);
$claims = json_decode(RsaJwk::base64UrlDecode($segments[1]), true);
if (! is_array($header) || ! is_array($claims)) {
throw new OidcTokenException('id_token contains invalid JSON.');
}
return [$header, $claims, $segments[0].'.'.$segments[1], RsaJwk::base64UrlDecode($segments[2])];
}
/**
* @param array<string, mixed> $jwks
* @return array<string, mixed>
*/
private function findJwk(array $jwks, string $kid): array
{
foreach ($jwks['keys'] ?? [] as $jwk) {
if (is_array($jwk) && ($jwk['kid'] ?? null) === $kid) {
return $jwk;
}
}
throw new OidcTokenException('No matching JWKS key found for id_token kid.');
}
/**
* @param array<string, mixed> $claims
*/
private function assertIssuer(array $claims, string $expectedIssuer): void
{
if (($claims['iss'] ?? null) !== $expectedIssuer) {
throw new OidcTokenException('id_token issuer does not match discovery issuer.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertAudience(array $claims, string $clientId): void
{
$audience = $claims['aud'] ?? null;
if (is_string($audience)) {
$audience = [$audience];
}
if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
throw new OidcTokenException('id_token audience does not include configured client id.');
}
if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
throw new OidcTokenException('id_token azp does not match configured client id.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertTimestamps(array $claims, int $clockSkewSeconds): void
{
$now = time();
$expiration = $claims['exp'] ?? null;
if (! is_int($expiration) || ($expiration + $clockSkewSeconds) < $now) {
throw new OidcTokenException('id_token is expired.');
}
$issuedAt = $claims['iat'] ?? null;
if (! is_int($issuedAt) || ($issuedAt - $clockSkewSeconds) > $now) {
throw new OidcTokenException('id_token issued at time is invalid.');
}
}
/**
* @param array<string, mixed> $claims
*/
private function assertNonce(array $claims, ?string $expectedNonce): void
{
if ($expectedNonce === null) {
return;
}
if (($claims['nonce'] ?? null) !== $expectedNonce) {
throw new OidcTokenException('id_token nonce does not match.');
}
}
}