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>
124 lines
3.7 KiB
PHP
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);
|
|
}
|
|
}
|