loreal-global-kickoff/JWTValidator.php
Vadym Samoilenko 53e9365c01 Add Azure AD SSO, RBAC (admin/user roles), and server-setup improvements
- Enable SSO with Azure AD credentials (tenant + client ID + redirect_uri)
- Add JWTValidator.php: RS256 idToken validation via Azure JWKS with 1h cache
- Add auth.php: POST login handler sets auth cookie, GET logout clears it
- Add UserRoleManager.php: file-based role CRUD in data/user_roles.json
- Add admin.php: admin-only role management panel
- AuthMiddleware: add requireAdmin(), role in user array, fix MSAL redirect
- header.php: hide Activity Logs + Admin Panel tabs for non-admin users
- logs-viewer.php: protect with requireAdmin() instead of requireAuth()
- server-setup.sh: add composer check, data/ dir, PHP extension checks, SSO validation
- .gitignore: add data/ directory

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

116 lines
3.4 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 [];
}
// 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);
}
}