Files

67 lines
2.4 KiB
PHP

<?php
declare(strict_types=1);
final class JwtToken
{
public static function issue(int $userId, string $sessionId, int $ttlSeconds): string
{
$key = AppConfig::required('JWT_SIGNING_KEY');
if (strlen($key) < 32) {
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
}
$now = time();
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = self::base64UrlEncode(json_encode([
'iss' => rtrim((string) (getenv('APP_URL') ?: ''), '/'),
'sub' => (string) $userId,
'sid' => $sessionId,
'iat' => $now,
'exp' => $now + $ttlSeconds,
'jti' => bin2hex(random_bytes(16)),
], JSON_UNESCAPED_SLASHES));
$signingInput = $header . '.' . $payload;
return $signingInput . '.' . self::base64UrlEncode(hash_hmac('sha256', $signingInput, $key, true));
}
public static function verify(string $token): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$headerPart, $payloadPart, $signaturePart] = $parts;
$header = json_decode(self::base64UrlDecode($headerPart), true);
$claims = json_decode(self::base64UrlDecode($payloadPart), true);
if (!is_array($header) || ($header['alg'] ?? null) !== 'HS256' || !is_array($claims)) {
return null;
}
try {
$key = AppConfig::required('JWT_SIGNING_KEY');
} catch (Throwable $exception) {
return null;
}
if (strlen($key) < 32) {
return null;
}
$expected = self::base64UrlEncode(hash_hmac('sha256', $headerPart . '.' . $payloadPart, $key, true));
if (!hash_equals($expected, $signaturePart) || (int) ($claims['exp'] ?? 0) <= time()) {
return null;
}
if (!ctype_digit((string) ($claims['sub'] ?? '')) || !is_string($claims['sid'] ?? null)) {
return null;
}
return $claims;
}
private static function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $value): string
{
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
return $decoded === false ? '' : $decoded;
}
}