Files
2026-07-14 15:01:25 +08:00

159 lines
4.7 KiB
PHP

<?php
namespace App\Support;
class JwtVerifier
{
/**
* Verify an HS256 JWT and return the payload, or null if invalid.
*
* @param array{issuer?: string, audience?: string, leeway?: int} $options
*/
public static function verify(string $token, string $secret, array $options = []): ?array
{
$result = self::diagnose($token, $secret, $options);
return $result['valid'] ? $result['payload'] : null;
}
/**
* @param array{issuer?: string, audience?: string, leeway?: int} $options
* @return array{
* valid: bool,
* reason: string|null,
* payload: array|null,
* claims: array<string, mixed>|null
* }
*/
public static function diagnose(string $token, string $secret, array $options = []): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return self::failure('malformed_token');
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
$expected = self::base64UrlEncode(
hash_hmac('sha256', "$headerB64.$payloadB64", $secret, true)
);
if (! hash_equals($expected, $signatureB64)) {
return self::failure('invalid_signature', null, self::decodePayload($payloadB64));
}
$header = json_decode(self::base64UrlDecode($headerB64), true);
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
if (! is_array($header) || ! is_array($payload)) {
return self::failure('invalid_json');
}
if (($header['alg'] ?? '') !== 'HS256') {
return self::failure('unsupported_algorithm', $payload);
}
$issuer = $options['issuer'] ?? null;
if ($issuer !== null && ($payload['iss'] ?? '') !== $issuer) {
return self::failure('issuer_mismatch', $payload);
}
$audience = $options['audience'] ?? null;
if ($audience !== null && ($payload['aud'] ?? '') !== $audience) {
return self::failure('audience_mismatch', $payload);
}
$leeway = (int) ($options['leeway'] ?? 0);
$now = time();
if (($payload['exp'] ?? 0) < ($now - $leeway)) {
return self::failure('token_expired', $payload);
}
if (($payload['nbf'] ?? 0) > ($now + $leeway)) {
return self::failure('token_not_yet_valid', $payload);
}
return [
'valid' => true,
'reason' => null,
'payload' => $payload,
'claims' => self::summarizeClaims($payload),
];
}
public static function isLaravelEncryptedSecret(?string $secret): bool
{
if (! is_string($secret) || $secret === '') {
return false;
}
$decoded = base64_decode($secret, true);
if ($decoded === false) {
return false;
}
$json = json_decode($decoded, true);
return is_array($json)
&& array_key_exists('iv', $json)
&& array_key_exists('value', $json)
&& array_key_exists('mac', $json);
}
/**
* @return array<string, mixed>|null
*/
protected static function decodePayload(string $payloadB64): ?array
{
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
return is_array($payload) ? $payload : null;
}
/**
* @param array<string, mixed>|null $payload
* @return array{valid: false, reason: string, payload: null, claims: array<string, mixed>|null}
*/
protected static function failure(string $reason, ?array $payload = null, ?array $claims = null): array
{
return [
'valid' => false,
'reason' => $reason,
'payload' => null,
'claims' => $claims ?? ($payload ? self::summarizeClaims($payload) : null),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected static function summarizeClaims(array $payload): array
{
return [
'iss' => $payload['iss'] ?? null,
'aud' => $payload['aud'] ?? null,
'exp' => $payload['exp'] ?? null,
'nbf' => $payload['nbf'] ?? null,
'ic_number' => $payload['ic_number'] ?? null,
'member_number' => $payload['member_number'] ?? null,
];
}
protected static function base64UrlDecode(string $data): string
{
$remainder = strlen($data) % 4;
if ($remainder) {
$data .= str_repeat('=', 4 - $remainder);
}
return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
}
protected static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}