<?php
declare(strict_types=1);

/**
 * Minimal JWT verifier. Supports RS256 only — that is the OIDC default and
 * what every major IdP (Keycloak, Google, Microsoft Entra) emits. Any other
 * `alg` claim (HS256, none, …) is rejected outright.
 *
 * The verifier walks the JWKS set, picks the key whose `kid` matches the JWT
 * header, converts the JWK (n, e in base64url) into a DER-then-PEM RSA public
 * key, and uses openssl_verify to check the signature against the
 * "header.payload" prefix.
 *
 * Caller is responsible for the standard claim checks (iss, aud, exp, nonce,
 * iat). This class only does the cryptographic part plus a trivial structural
 * decode.
 */
class OidcJwt
{
    /**
     * Returns the decoded payload (associative array) if the JWT is valid
     * and signed by one of the JWKS keys. Returns null on any failure.
     *
     * @param array $jwks  the full JWKS document (as returned by the provider's
     *                     /jwks endpoint, with a top-level "keys" array)
     */
    public static function verify(string $jwt, array $jwks): ?array
    {
        $parts = explode('.', $jwt);
        if (count($parts) !== 3) return null;
        [$h64, $p64, $s64] = $parts;

        $header  = self::decodeJsonPart($h64);
        $payload = self::decodeJsonPart($p64);
        if (!is_array($header) || !is_array($payload)) return null;

        // Reject everything but RS256 — explicit allow list. Stops the classic
        // "alg=none" and HS256-with-public-key-as-secret attacks.
        if (($header['alg'] ?? '') !== 'RS256') return null;

        $kid = (string)($header['kid'] ?? '');
        $key = self::pickKey($jwks, $kid);
        if ($key === null) return null;

        $pem = self::jwkRsaToPem($key);
        if ($pem === '') return null;

        $signature = self::base64UrlDecode($s64);
        if ($signature === '') return null;

        $signed = $h64 . '.' . $p64;
        $ok = openssl_verify($signed, $signature, $pem, OPENSSL_ALGO_SHA256);
        if ($ok !== 1) return null;

        return $payload;
    }

    public static function base64UrlDecode(string $s): string
    {
        $s = strtr($s, '-_', '+/');
        $pad = strlen($s) % 4;
        if ($pad) $s .= str_repeat('=', 4 - $pad);
        $out = base64_decode($s, true);
        return $out === false ? '' : $out;
    }

    public static function base64UrlEncode(string $s): string
    {
        return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
    }

    private static function decodeJsonPart(string $s): mixed
    {
        $raw = self::base64UrlDecode($s);
        if ($raw === '') return null;
        return json_decode($raw, true);
    }

    private static function pickKey(array $jwks, string $kid): ?array
    {
        $keys = $jwks['keys'] ?? [];
        if (!is_array($keys)) return null;
        // Prefer the kid-matched key. If the JWT has no kid (rare for OIDC),
        // fall back to the first RSA key — most providers send a kid though.
        foreach ($keys as $k) {
            if (!is_array($k) || ($k['kty'] ?? '') !== 'RSA') continue;
            if ($kid !== '' && (string)($k['kid'] ?? '') === $kid) return $k;
        }
        if ($kid === '') {
            foreach ($keys as $k) {
                if (is_array($k) && ($k['kty'] ?? '') === 'RSA') return $k;
            }
        }
        return null;
    }

    /**
     * Convert a JWK (RSA, with base64url-encoded `n` and `e`) to a PEM-encoded
     * RSA public key consumable by openssl_verify. Builds the DER ASN.1
     * structure manually to avoid any external dependency.
     */
    private static function jwkRsaToPem(array $jwk): string
    {
        $n = self::base64UrlDecode((string)($jwk['n'] ?? ''));
        $e = self::base64UrlDecode((string)($jwk['e'] ?? ''));
        if ($n === '' || $e === '') return '';

        // ASN.1: SEQUENCE { INTEGER n, INTEGER e }
        $seq = self::asnInt($n) . self::asnInt($e);
        $rsaPubKey = self::asnSequence($seq);

        // SubjectPublicKeyInfo
        //   SEQUENCE {
        //     SEQUENCE { OID rsaEncryption, NULL },
        //     BIT STRING { rsaPubKey }
        //   }
        $oidRsa  = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01"; // 1.2.840.113549.1.1.1
        $algoSeq = self::asnSequence($oidRsa . "\x05\x00");
        $bitStr  = self::asnBitString($rsaPubKey);
        $der     = self::asnSequence($algoSeq . $bitStr);

        $b64 = chunk_split(base64_encode($der), 64, "\n");
        return "-----BEGIN PUBLIC KEY-----\n" . $b64 . "-----END PUBLIC KEY-----\n";
    }

    private static function asnLength(int $len): string
    {
        if ($len < 0x80) return chr($len);
        $hex = ltrim(dechex($len), '0');
        if (strlen($hex) % 2 !== 0) $hex = '0' . $hex;
        $bytes = hex2bin($hex);
        return chr(0x80 | strlen($bytes)) . $bytes;
    }

    private static function asnInt(string $bytes): string
    {
        // Strip leading zeros, then add ONE leading 0x00 if the high bit of
        // the first byte is set (otherwise the value would be parsed as negative).
        $bytes = ltrim($bytes, "\x00");
        if ($bytes === '') $bytes = "\x00";
        if ((ord($bytes[0]) & 0x80) !== 0) $bytes = "\x00" . $bytes;
        return "\x02" . self::asnLength(strlen($bytes)) . $bytes;
    }

    private static function asnSequence(string $body): string
    {
        return "\x30" . self::asnLength(strlen($body)) . $body;
    }

    private static function asnBitString(string $body): string
    {
        // BIT STRING: 0x03, length, unusedBits=0x00, content
        $body = "\x00" . $body;
        return "\x03" . self::asnLength(strlen($body)) . $body;
    }
}
