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