loreal-global-kickoff/JWTValidator.php
Vadym Samoilenko c7ea114fcf Fix JWKS parsing: inject alg=RS256 for Azure AD keys
firebase/php-jwt v6 requires 'alg' in each JWK, but Azure AD JWKS
endpoint omits it. Inject RS256 for any key missing the parameter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:42:08 +00:00

124 lines
3.7 KiB
PHP

<?php
/**
* JWT Validator for Azure AD v2 tokens (RS256)
* Validates idTokens issued by Microsoft identity platform
*/
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
class JWTValidator {
private string $tenantId;
private string $clientId;
private string $cacheFile;
public function __construct(string $tenantId, string $clientId) {
$this->tenantId = $tenantId;
$this->clientId = $clientId;
$this->cacheFile = __DIR__ . '/data/jwks_cache.json';
}
/**
* Validate an Azure AD idToken
* @return array ['valid' => bool, 'claims' => array] or ['valid' => false, 'error' => string]
*/
public function validate(string $token): array {
try {
$keys = $this->getPublicKeys();
if (empty($keys)) {
return ['valid' => false, 'error' => 'Failed to load public keys'];
}
$decoded = JWT::decode($token, JWK::parseKeySet($keys));
$claims = (array) $decoded;
// Validate issuer (v2 endpoint)
$expectedIss = "https://login.microsoftonline.com/{$this->tenantId}/v2.0";
if (($claims['iss'] ?? '') !== $expectedIss) {
return ['valid' => false, 'error' => 'Invalid token issuer'];
}
// Validate audience
if (($claims['aud'] ?? '') !== $this->clientId) {
return ['valid' => false, 'error' => 'Invalid token audience'];
}
// Validate expiry (JWT::decode already checks this, but being explicit)
if (($claims['exp'] ?? 0) < time()) {
return ['valid' => false, 'error' => 'Token has expired'];
}
return ['valid' => true, 'claims' => $claims];
} catch (Exception $e) {
return ['valid' => false, 'error' => 'Token validation failed: ' . $e->getMessage()];
}
}
/**
* Fetch JWKS keys from Azure AD with 1-hour file cache
*/
private function getPublicKeys(): array {
// Check cache
if (file_exists($this->cacheFile)) {
$cached = json_decode(file_get_contents($this->cacheFile), true);
if ($cached && isset($cached['expires']) && $cached['expires'] > time()) {
return $cached['keys'];
}
}
// Fetch OIDC metadata
$metadataUrl = "https://login.microsoftonline.com/{$this->tenantId}/v2.0/.well-known/openid-configuration";
$metadata = $this->httpGet($metadataUrl);
if (!$metadata || !isset($metadata['jwks_uri'])) {
return [];
}
// Fetch JWKS
$jwks = $this->httpGet($metadata['jwks_uri']);
if (!$jwks || !isset($jwks['keys'])) {
return [];
}
// Azure AD omits 'alg' from JWKS keys; firebase/php-jwt v6 requires it
foreach ($jwks['keys'] as &$key) {
if (!isset($key['alg'])) {
$key['alg'] = 'RS256';
}
}
unset($key);
// Cache for 1 hour
$cacheDir = dirname($this->cacheFile);
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
file_put_contents($this->cacheFile, json_encode([
'expires' => time() + 3600,
'keys' => $jwks
]));
return $jwks;
}
private function httpGet(string $url): ?array {
$ctx = stream_context_create([
'http' => [
'timeout' => 10,
'user_agent' => 'PHP/JWTValidator'
]
]);
$response = @file_get_contents($url, false, $ctx);
if ($response === false) {
return null;
}
return json_decode($response, true);
}
}