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