SIMON Master Console Rebuild
True command center for SIMON + WEB360
This rebuild gives you a cleaner production spine: interactive tree, live viewer, guarded editor, dynamic graphics, architecture map, and note storage. It is designed to sit beside your existing
console.php
and become the stronger replacement path.
Dashboard
Tree
Viewer
System Map
File Registry
Notes
Files
15,026
Folders
377
Scanned Size
3.79 GB
PHP Files
791
Editable Text Files
12,310
File viewer
guarded to /htdocs
/api/auth/SecurityTokenValidator.php
<?php declare(strict_types=1); /** * /api/auth/SecurityTokenValidator.php * * Guardian Security Token Validator * * Validates OIDC ID tokens for: * - Google * - Apple * * Verifies: * - JWT structure * - signature (RS256) * - issuer * - audience * - expiration * - issued-at * - nonce * - subject * - email / email_verified * * Requirements: * - PHP OpenSSL extension enabled * - cURL enabled * * Expected constants (usually from session_bootstrap.php or config): * - GOOGLE_CLIENT_ID * - APPLE_CLIENT_ID * * Optional: * - GUARDIAN_JWKS_CACHE_DIR * - GUARDIAN_JWKS_CACHE_TTL_FALLBACK */ final class SecurityTokenValidator { private const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'; private const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys'; private const GOOGLE_ISSUERS = [ 'accounts.google.com', 'https://accounts.google.com', ]; private const APPLE_ISSUERS = [ 'https://appleid.apple.com', ]; private const CLOCK_SKEW = 300; // 5 minutes /** * Validate a provider ID token and return normalized claims. * * @param string $provider google|apple * @param string $jwt Raw ID token * @param string $expectedAud OAuth client ID / Services ID * @param string|null $expectedNonce Nonce stored in session during auth start * @return array{ * valid: bool, * provider: string, * subject: ?string, * email: ?string, * email_verified: bool, * issuer_verified: bool, * audience_verified: bool, * expiration_verified: bool, * nonce_verified: bool, * signature_verified: bool, * claims: array<string,mixed>, * error: ?string * } */ public static function validate( string $provider, string $jwt, string $expectedAud, ?string $expectedNonce = null ): array { $provider = strtolower(trim($provider)); $result = self::emptyResult($provider); try { if ($jwt === '') { throw new RuntimeException('Missing token.'); } if ($expectedAud === '') { throw new RuntimeException('Missing expected audience.'); } $decoded = self::decodeJwt($jwt); $header = $decoded['header']; $payload = $decoded['payload']; $sig = $decoded['signature']; $signed = $decoded['signed_part']; if (($header['alg'] ?? '') !== 'RS256') { throw new RuntimeException('Unsupported JWT algorithm.'); } $kid = (string)($header['kid'] ?? ''); if ($kid === '') { throw new RuntimeException('Missing JWT key ID.'); } $jwk = match ($provider) { 'google' => self::findJwkByKid(self::fetchJwksCached('google', self::GOOGLE_JWKS_URL), $kid), 'apple' => self::findJwkByKid(self::fetchJwksCached('apple', self::APPLE_JWKS_URL), $kid), default => throw new RuntimeException('Unsupported provider.'), }; $pem = self::jwkToPem($jwk); $signatureVerified = self::verifyRs256Signature($signed, $sig, $pem); if (!$signatureVerified) { throw new RuntimeException('JWT signature verification failed.'); } $result['signature_verified'] = true; $issuer = (string)($payload['iss'] ?? ''); $result['issuer_verified'] = match ($provider) { 'google' => in_array($issuer, self::GOOGLE_ISSUERS, true), 'apple' => in_array($issuer, self::APPLE_ISSUERS, true), default => false, }; if (!$result['issuer_verified']) { throw new RuntimeException('Issuer verification failed.'); } $aud = $payload['aud'] ?? null; $result['audience_verified'] = self::audienceMatches($aud, $expectedAud); if (!$result['audience_verified']) { throw new RuntimeException('Audience verification failed.'); } $now = time(); $exp = isset($payload['exp']) ? (int)$payload['exp'] : 0; $iat = isset($payload['iat']) ? (int)$payload['iat'] : 0; $expOk = ($exp > 0) && ($exp + self::CLOCK_SKEW >= $now); $iatOk = ($iat > 0) && ($iat - self::CLOCK_SKEW <= $now); $result['expiration_verified'] = $expOk && $iatOk; if (!$result['expiration_verified']) { throw new RuntimeException('Token time validation failed.'); } $nonceClaim = isset($payload['nonce']) ? (string)$payload['nonce'] : ''; if ($expectedNonce !== null && $expectedNonce !== '') { $result['nonce_verified'] = hash_equals($expectedNonce, $nonceClaim); if (!$result['nonce_verified']) { throw new RuntimeException('Nonce verification failed.'); } } else { // If caller intentionally omitted nonce, mark true only when not required. $result['nonce_verified'] = true; } $sub = trim((string)($payload['sub'] ?? '')); if ($sub === '') { throw new RuntimeException('Missing subject claim.'); } $email = self::normalizeEmail((string)($payload['email'] ?? '')); $emailVerified = self::parseBooleanClaim($payload['email_verified'] ?? false); // Google typically should have email_verified true for email-based identity use. // Apple may omit email on later logins; sub is the durable key. if ($provider === 'google' && $email !== '' && !$emailVerified) { throw new RuntimeException('Google email is not verified.'); } $result['valid'] = true; $result['subject'] = $sub; $result['email'] = ($email !== '') ? $email : null; $result['email_verified'] = $emailVerified; $result['claims'] = $payload; $result['error'] = null; return $result; } catch (Throwable $e) { $result['error'] = $e->getMessage(); return $result; } } /** * Convenience validator using configured constants. */ public static function validateForConfiguredProvider( string $provider, string $jwt, ?string $expectedNonce = null ): array { $provider = strtolower(trim($provider)); $aud = match ($provider) { 'google' => defined('GOOGLE_CLIENT_ID') ? (string)GOOGLE_CLIENT_ID : '', 'apple' => defined('APPLE_CLIENT_ID') ? (string)APPLE_CLIENT_ID : '', default => '', }; return self::validate($provider, $jwt, $aud, $expectedNonce); } private static function emptyResult(string $provider): array { return [ 'valid' => false, 'provider' => $provider, 'subject' => null, 'email' => null, 'email_verified' => false, 'issuer_verified' => false, 'audience_verified' => false, 'expiration_verified' => false, 'nonce_verified' => false, 'signature_verified' => false, 'claims' => [], 'error' => 'Unknown validation error.', ]; } /** * @return array{header: array<string,mixed>, payload: array<string,mixed>, signature: string, signed_part: string} */ private static function decodeJwt(string $jwt): array { $parts = explode('.', $jwt); if (count($parts) !== 3) { throw new RuntimeException('Malformed JWT.'); } [$encodedHeader, $encodedPayload, $encodedSignature] = $parts; $header = json_decode(self::base64UrlDecode($encodedHeader), true); $payload = json_decode(self::base64UrlDecode($encodedPayload), true); $signature = self::base64UrlDecodeRaw($encodedSignature); if (!is_array($header) || !is_array($payload) || $signature === '') { throw new RuntimeException('Invalid JWT payload.'); } return [ 'header' => $header, 'payload' => $payload, 'signature' => $signature, 'signed_part' => $encodedHeader . '.' . $encodedPayload, ]; } /** * @return array<int,array<string,mixed>> */ private static function fetchJwksCached(string $provider, string $url): array { $cacheDir = defined('GUARDIAN_JWKS_CACHE_DIR') ? (string)GUARDIAN_JWKS_CACHE_DIR : sys_get_temp_dir() . '/guardian_jwks'; $fallbackTtl = defined('GUARDIAN_JWKS_CACHE_TTL_FALLBACK') ? max(60, (int)GUARDIAN_JWKS_CACHE_TTL_FALLBACK) : 3600; if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0775, true); } $cacheFile = rtrim($cacheDir, '/\\') . '/' . $provider . '_jwks.json'; $metaFile = rtrim($cacheDir, '/\\') . '/' . $provider . '_jwks.meta.json'; $cachedBody = null; $cachedMeta = null; if (is_file($cacheFile)) { $raw = @file_get_contents($cacheFile); if ($raw !== false && trim($raw) !== '') { $cachedBody = $raw; } } if (is_file($metaFile)) { $raw = @file_get_contents($metaFile); $meta = ($raw !== false) ? json_decode($raw, true) : null; if (is_array($meta)) { $cachedMeta = $meta; } } $cacheExpiresAt = isset($cachedMeta['expires_at']) ? (int)$cachedMeta['expires_at'] : 0; if ($cachedBody !== null && $cacheExpiresAt > time()) { $data = json_decode($cachedBody, true); $keys = $data['keys'] ?? null; if (is_array($keys)) { return $keys; } } $responseHeaders = []; $body = self::httpGet($url, $responseHeaders); $data = json_decode($body, true); $keys = $data['keys'] ?? null; if (!is_array($keys)) { if ($cachedBody !== null) { $fallbackData = json_decode($cachedBody, true); $fallbackKeys = $fallbackData['keys'] ?? null; if (is_array($fallbackKeys)) { return $fallbackKeys; } } throw new RuntimeException('Unable to load JWKS.'); } $ttl = self::extractCacheTtl($responseHeaders, $fallbackTtl); @file_put_contents($cacheFile, $body, LOCK_EX); @file_put_contents($metaFile, json_encode([ 'provider' => $provider, 'fetched_at' => time(), 'expires_at' => time() + $ttl, 'source' => $url, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX); return $keys; } /** * @param array<int,array<string,mixed>> $jwks * @return array<string,mixed> */ private static function findJwkByKid(array $jwks, string $kid): array { foreach ($jwks as $jwk) { if ((string)($jwk['kid'] ?? '') === $kid) { return $jwk; } } throw new RuntimeException('Matching JWK not found.'); } /** * Convert RSA JWK to PEM public key. * * @param array<string,mixed> $jwk */ private static function jwkToPem(array $jwk): string { if (($jwk['kty'] ?? '') !== 'RSA') { throw new RuntimeException('Unsupported JWK key type.'); } $n = self::base64UrlDecodeRaw((string)($jwk['n'] ?? '')); $e = self::base64UrlDecodeRaw((string)($jwk['e'] ?? '')); if ($n === '' || $e === '') { throw new RuntimeException('Invalid RSA JWK.'); } $modulus = self::asn1EncodeInteger($n); $exponent = self::asn1EncodeInteger($e); $rsaPublicKey = self::asn1EncodeSequence($modulus . $exponent); // rsaEncryption OID: 1.2.840.113549.1.1.1 $algorithmIdentifier = hex2bin('300d06092a864886f70d0101010500'); if ($algorithmIdentifier === false) { throw new RuntimeException('Failed to build ASN.1 algorithm identifier.'); } $subjectPublicKeyInfo = self::asn1EncodeSequence( $algorithmIdentifier . self::asn1EncodeBitString($rsaPublicKey) ); $pem = "-----BEGIN PUBLIC KEY-----\n"; $pem .= chunk_split(base64_encode($subjectPublicKeyInfo), 64, "\n"); $pem .= "-----END PUBLIC KEY-----\n"; return $pem; } private static function verifyRs256Signature(string $signedData, string $signature, string $pem): bool { $ok = openssl_verify($signedData, $signature, $pem, OPENSSL_ALGO_SHA256); return $ok === 1; } /** * @param mixed $aud */ private static function audienceMatches(mixed $aud, string $expectedAud): bool { if (is_string($aud)) { return hash_equals($expectedAud, $aud); } if (is_array($aud)) { foreach ($aud as $candidate) { if (is_string($candidate) && hash_equals($expectedAud, $candidate)) { return true; } } } return false; } /** * @param mixed $value */ private static function parseBooleanClaim(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_string($value)) { $v = strtolower(trim($value)); return in_array($v, ['1', 'true', 'yes'], true); } if (is_int($value)) { return $value === 1; } return false; } private static function normalizeEmail(string $email): string { return strtolower(trim($email)); } /** * @param array<string,string> $headers */ private static function extractCacheTtl(array $headers, int $fallbackTtl): int { $cacheControl = ''; foreach ($headers as $name => $value) { if (strtolower($name) === 'cache-control') { $cacheControl = $value; break; } } if ($cacheControl !== '' && preg_match('/max-age=(\d+)/i', $cacheControl, $m)) { $ttl = (int)$m[1]; if ($ttl > 0) { return $ttl; } } return $fallbackTtl; } /** * @param array<string,string> $responseHeaders */ private static function httpGet(string $url, array &$responseHeaders = []): string { $responseHeaders = []; $ch = curl_init($url); if ($ch === false) { throw new RuntimeException('Unable to initialize cURL.'); } curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => [ 'Accept: application/json', ], CURLOPT_HEADERFUNCTION => static function ($ch, string $headerLine) use (&$responseHeaders): int { $len = strlen($headerLine); $parts = explode(':', $headerLine, 2); if (count($parts) === 2) { $name = trim($parts[0]); $value = trim($parts[1]); if ($name !== '') { $responseHeaders[$name] = $value; } } return $len; }, ]); $body = curl_exec($ch); if ($body === false) { $err = curl_error($ch); curl_close($ch); throw new RuntimeException('JWKS fetch failed: ' . $err); } $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status < 200 || $status >= 300) { throw new RuntimeException('JWKS fetch returned HTTP ' . $status . '.'); } return (string)$body; } private static function base64UrlDecode(string $value): string { $decoded = self::base64UrlDecodeRaw($value); if ($decoded === '') { throw new RuntimeException('Base64url decode failed.'); } return $decoded; } private static function base64UrlDecodeRaw(string $value): string { $remainder = strlen($value) % 4; if ($remainder > 0) { $value .= str_repeat('=', 4 - $remainder); } $decoded = base64_decode(strtr($value, '-_', '+/'), true); return ($decoded === false) ? '' : $decoded; } private static function asn1EncodeLength(int $length): string { if ($length < 0x80) { return chr($length); } $temp = ''; while ($length > 0) { $temp = chr($length & 0xFF) . $temp; $length >>= 8; } return chr(0x80 | strlen($temp)) . $temp; } private static function asn1EncodeInteger(string $value): string { if (ord($value[0]) > 0x7F) { $value = "\x00" . $value; } return "\x02" . self::asn1EncodeLength(strlen($value)) . $value; } private static function asn1EncodeSequence(string $value): string { return "\x30" . self::asn1EncodeLength(strlen($value)) . $value; } private static function asn1EncodeBitString(string $value): string { return "\x03" . self::asn1EncodeLength(strlen($value) + 1) . "\x00" . $value; } }
Save file
Quick jump
open a path
Open