Implemented complete Microsoft Authentication Library (MSAL) / Azure AD Single Sign-On (SSO) system following Ferrero app pattern. KEY FEATURE: Toggle authentication on/off via environment variable - SSO_ENABLED=false → Mock user, no login required (local dev) - SSO_ENABLED=true → Full Azure AD authentication (production) NEW FILES: - composer.json - Firebase JWT dependency - .env.example - Environment variable template - env_loader.php - Parse .env file - JWTValidator.php - Validate JWT tokens from Azure AD - AuthMiddleware.php - Core auth orchestrator with login UI - auth.php - Authentication API (login/logout/status) - auth-test.php - Debug authentication status - AUTH_README.md - Complete setup documentation UPDATED FILES: - config.php - Load env vars, add SSO constants - index.php - Require auth, add logout button, MSAL script - api.php - Add authentication check - enhance_prompt.php - Add authentication check - .gitignore - Exclude .env and vendor/ AUTHENTICATION FLOW: 1. User visits app → Auth check 2. If SSO disabled → Mock "Local Developer" user 3. If SSO enabled → Validate JWT from cookie 4. If no token → Show MSAL login page 5. User signs in → Token validated → Cookie set → App loads SECURITY FEATURES: ✅ httpOnly cookies (XSS prevention) ✅ SameSite=Lax (CSRF prevention) ✅ JWT signature validation ✅ Claims validation (exp, nbf, aud, iss) ✅ JWKS from Azure AD ✅ 24-hour token expiration ✅ Secure flag for HTTPS DEPENDENCIES INSTALLED: - firebase/php-jwt v6.11.1 TESTING: - Local: SSO disabled by default in .env - Server: Set SSO_ENABLED=true with Azure AD credentials - Cannot test MSAL locally (redirect URI bound to server) DEPLOYMENT: 1. Install composer dependencies 2. Configure .env with Azure AD credentials 3. Set SSO_ENABLED=true when ready 4. Visit auth-test.php to verify setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
196 lines
6.2 KiB
PHP
196 lines
6.2 KiB
PHP
<?php
|
|
/**
|
|
* JWT Token Validator for Azure AD / MSAL
|
|
* Validates JWT tokens from Microsoft Azure AD using JWKS
|
|
*/
|
|
|
|
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;
|
|
}
|
|
}
|