pencil_automator/JWTValidator.php

201 lines
6.3 KiB
PHP

<?php
/**
* JWT Token Validator for Azure AD / MSAL
* Validates JWT tokens from Microsoft Azure AD using JWKS
*/
// Load Composer autoload for Firebase JWT library
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
class JWTValidator {
private $tenantId;
private $clientId;
private $jwksCache = null;
private $jwksCacheTime = 0;
private $jwksCacheDuration = 3600; // Cache for 1 hour
public function __construct($tenantId, $clientId) {
$this->tenantId = $tenantId;
$this->clientId = $clientId;
}
/**
* Validate a JWT token
*
* @param string $token The JWT token to validate
* @return array ['valid' => bool, 'payload' => array|null, 'error' => string|null]
*/
public function validateToken($token) {
if (empty($token)) {
return ['valid' => false, 'error' => 'Token is empty'];
}
try {
// Get public keys from Azure AD
$jwks = $this->getJWKS();
if (!$jwks) {
return ['valid' => false, 'error' => 'Could not retrieve public keys from Azure AD'];
}
// Convert JWKS to Key objects
$keys = JWK::parseKeySet($jwks);
// Decode and validate the JWT
$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;
// Validate claims
$validation = $this->validateClaims($payload);
if (!$validation['valid']) {
return $validation;
}
return ['valid' => true, 'payload' => $payload];
} catch (\Firebase\JWT\ExpiredException $e) {
return ['valid' => false, 'error' => 'Token has expired'];
} catch (\Firebase\JWT\SignatureInvalidException $e) {
return ['valid' => false, 'error' => 'Token signature is invalid'];
} catch (\Firebase\JWT\BeforeValidException $e) {
return ['valid' => false, 'error' => 'Token is not yet valid'];
} catch (Exception $e) {
return ['valid' => false, 'error' => 'JWT validation failed: ' . $e->getMessage()];
}
}
/**
* Validate JWT claims
*
* @param array $payload The decoded JWT payload
* @return array ['valid' => bool, 'error' => string|null]
*/
private function validateClaims($payload) {
$now = time();
// Check expiration (exp claim)
if (isset($payload['exp']) && $payload['exp'] < $now) {
return ['valid' => false, 'error' => 'Token has expired'];
}
// Check not-before (nbf claim)
if (isset($payload['nbf']) && $payload['nbf'] > $now) {
return ['valid' => false, 'error' => 'Token is not yet valid'];
}
// Validate audience (aud claim) - must be our client ID or Microsoft Graph
if (isset($payload['aud'])) {
$validAudiences = [
$this->clientId,
'00000003-0000-0000-c000-000000000000', // Microsoft Graph
'https://graph.microsoft.com'
];
if (!in_array($payload['aud'], $validAudiences)) {
return ['valid' => false, 'error' => 'Invalid audience: ' . $payload['aud']];
}
}
// Validate issuer (iss claim) - must be from Azure AD tenant
if (isset($payload['iss'])) {
$validIssuers = [
"https://login.microsoftonline.com/{$this->tenantId}/v2.0",
"https://login.microsoftonline.com/{$this->tenantId}/",
"https://sts.windows.net/{$this->tenantId}/",
"https://login.microsoftonline.com/common/v2.0"
];
if (!in_array($payload['iss'], $validIssuers)) {
return ['valid' => false, 'error' => 'Invalid issuer: ' . $payload['iss']];
}
}
return ['valid' => true];
}
/**
* Get JWKS (JSON Web Key Set) from Azure AD
*
* @return array|null
*/
private function getJWKS() {
// Return cached JWKS if still valid
if ($this->jwksCache && (time() - $this->jwksCacheTime) < $this->jwksCacheDuration) {
return $this->jwksCache;
}
// Get OpenID configuration from Azure AD
$configUrl = "https://login.microsoftonline.com/{$this->tenantId}/v2.0/.well-known/openid-configuration";
$config = $this->fetchJson($configUrl);
if (!$config || !isset($config['jwks_uri'])) {
error_log("Failed to get OpenID configuration from: $configUrl");
return null;
}
// Fetch JWKS from jwks_uri
$jwks = $this->fetchJson($config['jwks_uri']);
if (!$jwks || !isset($jwks['keys'])) {
error_log("Failed to get JWKS from: " . $config['jwks_uri']);
return null;
}
// Ensure all keys have the 'alg' parameter
foreach ($jwks['keys'] as &$key) {
if (!isset($key['alg'])) {
// Default to RS256 for RSA keys
if (isset($key['kty']) && $key['kty'] === 'RSA') {
$key['alg'] = 'RS256';
}
}
}
// Cache the JWKS
$this->jwksCache = $jwks;
$this->jwksCacheTime = time();
return $jwks;
}
/**
* Fetch JSON from a URL
*
* @param string $url
* @return array|null
*/
private function fetchJson($url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("CURL Error fetching $url: $curlError");
return null;
}
if ($httpCode !== 200) {
error_log("HTTP Error $httpCode fetching $url");
return null;
}
$json = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("JSON decode error fetching $url: " . json_last_error_msg());
return null;
}
return $json;
}
}