From f995810a6b4962d6e4b5153d27fe1ab4496f8eef Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 31 Dec 2025 23:04:18 +0100 Subject: [PATCH 1/3] Add JWT::from() and JWT::tryFrom() to parse JWTs from a given token --- .../web/auth/oauth/ByCertificate.class.php | 2 +- src/main/php/web/auth/oauth/JWT.class.php | 93 ++++++++++-- .../php/web/auth/unittest/JWTTest.class.php | 140 ++++++++++++++++++ 3 files changed, 225 insertions(+), 10 deletions(-) create mode 100755 src/test/php/web/auth/unittest/JWTTest.class.php diff --git a/src/main/php/web/auth/oauth/ByCertificate.class.php b/src/main/php/web/auth/oauth/ByCertificate.class.php index fff28c6..382dde6 100644 --- a/src/main/php/web/auth/oauth/ByCertificate.class.php +++ b/src/main/php/web/auth/oauth/ByCertificate.class.php @@ -36,7 +36,7 @@ public function __construct($clientId, $fingerprint, $privateKey, $validity= 360 /** Returns parameters to be used in authentication process */ public function params(string $endpoint, $time= null): array { $time ?? $time= time(); - $jwt= new JWT(['alg' => 'RS256', 'typ' => 'JWT', 'x5t' => JWT::base64(hex2bin($this->fingerprint))], [ + $jwt= new JWT(['alg' => 'RS256', 'typ' => 'JWT', 'x5t' => JWT::encode(hex2bin($this->fingerprint))], [ 'aud' => $endpoint, 'exp' => $time + $this->validity, 'iss' => $this->key, diff --git a/src/main/php/web/auth/oauth/JWT.class.php b/src/main/php/web/auth/oauth/JWT.class.php index 8337b62..e46ee41 100644 --- a/src/main/php/web/auth/oauth/JWT.class.php +++ b/src/main/php/web/auth/oauth/JWT.class.php @@ -7,6 +7,7 @@ * * @see https://tools.ietf.org/html/rfc7519 * @see https://developer.okta.com/blog/2019/02/04/create-and-verify-jwts-in-php + * @test web.auth.unittest.JWTTest * @ext openssl */ class JWT { @@ -14,32 +15,106 @@ class JWT { /** Creates a new JWT with a given header and payload */ public function __construct(array $header, array $payload) { - $this->header= $header; + $this->header= ['alg' => 'RS256'] + $header; $this->payload= $payload; } + /** @return [:string] */ + public function header() { return $this->header; } + + /** @return [:string] */ + public function payload() { return $this->payload; } + + /** + * Returns registered and custom claims, or NULL if there is no claim + * present by the given name in the payload. + * + * @return var + */ + public function claim(string $name) { return $this->payload[$name] ?? null; } + /** URL-safe Base64 encoding */ - public static function base64(string $bytes): string { - return strtr(rtrim(base64_encode($bytes), '='), '+/', '-_'); + public static function encode(string $input): string { + return strtr(rtrim(base64_encode($input), '='), '+/', '-_'); + } + + /** URL-safe Base64 decoding */ + public static function decode(string $input): string { + return base64_decode(strtr($input, '-_', '+/')); } /** - * Sign JWT and return token + * Sign JWT with a private key and return token * - * @param OpenSSLAsymmetricKey $key + * @param string|OpenSSLAsymmetricKey $privateKey * @return string * @throws lang.IllegalStateException if signing fails */ - public function sign($key): string { - $input= self::base64(json_encode($this->header)).'.'.self::base64(json_encode($this->payload)); + public function sign($privateKey): string { + $input= self::encode(json_encode($this->header)).'.'.self::encode(json_encode($this->payload)); // Hardcode SHA256 signing via OpenSSL here, would need algorithm-based // handling in order for this to be a full implementation, see e.g. // https://github.com/firebase/php-jwt/blob/v6.2.0/src/JWT.php#L220 - if (!openssl_sign($input, $signature, $key, 'SHA256')) { + if (!openssl_sign($input, $signature, $privateKey, 'SHA256')) { throw new IllegalStateException(openssl_error_string()); } - return $input.'.'.self::base64($signature); + return $input.'.'.self::encode($signature); + } + + /** Helper to parse */ + private static function parse($token, $publicKey) { + $parts= explode('.', $token, 3); + if (3 !== sizeof($parts)) { + return [null, 'Expected [header].[payload].[signature]']; + } + + // Restrict supported algorithms to RS256, see comment in sign() above! + $header= json_decode(self::decode($parts[0]), true); + if (json_last_error()) { + return [null, 'Header parsing error: '.json_last_error_msg()]; + } else if ('RS256' !== ($alg= $header['alg'] ?? '(null)')) { + return [null, 'Unsupported algorithm '.$alg]; + } + + $payload= json_decode(self::decode($parts[1]), true); + if (json_last_error()) { + return [null, 'Payload parsing error: '.json_last_error_msg()]; + } + + // Returns 1 if the signature is correct, 0 if it is incorrect, and -1 or false on error. + if (1 !== openssl_verify($parts[0].'.'.$parts[1], self::decode($parts[2]), $publicKey, 'SHA256')) { + return [null, 'Signature mismatch: '.openssl_error_string()]; + } + + return [new self($header, $payload), null]; + } + + /** + * Parse token into a JWT, verifying its signature with the public key + * + * @param string $token + * @param string|OpenSSLAsymmetricKey $publicKey + * @return self + * @throws lang.IllegalStateException if verification fails + */ + public static function from(string $token, $publicKey): self { + [$jwt, $err]= self::parse($token, $publicKey); + + // TODO (PHP 8): Migrate to throw expressions + return $jwt ?? (function() use($err) { throw new IllegalStateException($err); })(); + } + + /** + * Try to parse token into a JWT and verify its signature with the public key + * + * @param string $token + * @param string|OpenSSLAsymmetricKey $publicKey + * @return ?self + */ + public static function tryFrom(string $token, $publicKey): ?self { + [$jwt, $err]= self::parse($token, $publicKey); + return $jwt; } } \ No newline at end of file diff --git a/src/test/php/web/auth/unittest/JWTTest.class.php b/src/test/php/web/auth/unittest/JWTTest.class.php new file mode 100755 index 0000000..c8461ad --- /dev/null +++ b/src/test/php/web/auth/unittest/JWTTest.class.php @@ -0,0 +1,140 @@ + 'RS256', 'typ' => 'JWT']; + const TOKEN= [ + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4cC10ZXN0aW5nI', + 'iwic3ViIjoidGVzdCJ9.LAI3asY6s3ObdWchBYmVBh4hVNztWlTLAdKA-6fqYx', + 'tsouo90G9q0OXQ26axz9j0CbQ-nLBeDVSQ4c1ay69Ot13OnGsBSL1mT9WVgCyu', + 'JUInDCtD34j3hefqmVz4lVK6-QI7jpSCeff-W-T3rom7-atnQ3UZBNlX3CBzNi', + 'ZDMA1WRubcbfKjD0D8D6hSxq7LL0YrDhC8xvAtlzB3NMZUDJ56GAG1tAIAuMsP', + '8iQFQNp97Wxa-13Z08etsdhj5-mZvY0251NOa3EUe2ykwh9FSLowUqX0aNppPI', + '8sVGVsfoiu2DyElLBNcya6_sN4xm7otS3vA-prNRg66SUn-7QGWw' + ]; + const PRIVATE_KEY= <<<'PRIVATE_KEY' + -----BEGIN RSA PRIVATE KEY----- + MIIEowIBAAKCAQEAsxFFSy1nXqchsH5sNr7v0bYrP5KAlKUPZxa2rNCD0uxsYy2+ + K9XFTCU2uQJFpkBkiUMUdYTfSoI7lqUkgYf2X30S8Bc9hV0C656yK7kA00EOZrkD + gpooJwGjDd9R60baHdSYnMh9EIiCB8XR8C22Ha3Uso0v3EsDvor5k3HzN8dkBJkZ + s9fJqf/K5P22HDuC/EhIDxdrODhwhoshpydN+FHwt8V4uuhULOvj8mFW0ooUUuyY + T/qWtjOmV0KhIZmtIyQBwe62SKxNQttdJ6wq55gCTmWIWw26Q4x/Q8bcHgF13yEa + 9NcG6AWHiU3ln7tqqplPApk1erKWjdXERbpD0QIDAQABAoIBAQCTzTeS28EswWrv + UQplDajJQkHkUTpMdwmFn5vcfKeyW28DVehYKjSVq0nF33g5x4C0Q2gJsEjWKTSi + HWFKgTz8iDIvdh9Tivg0H2MU77kcpeALLb8V98QYniNF+gSV3H+Ai9AD6QBBu0sI + u2GTi0d8q9SaJCtS+5/1kKR77VxBtrLnMFMC11DoB1bazRNBZsBC6NvTZVJZGdvq + q86CF6s+DKCTTN0J5GcQVX8hvNKyGe8n6rUyxflDejJXEzo/k+zeitVvPOie3cv6 + fOyfslacM8Gapy7dyXYnkTX4gswyGVCbwO2BNFWr1xvpadXE9hK4VlqIC3t1GzYq + Px0Y0eJ5AoGBANvPY61WPfdRuSpzhlmVrq+urvfFw9iRj1nbMmwuG2VwWU+DyCnr + Oawl/OoshVBg31Z7LMWQuLjTT1uVb+rVLxmzpf6+/6vZhKSTwjJT9gOOQOhNFAZV + 1D6+y0FZrIfVDubaqheWEKk7RAKddInZEdUMEY6zA73cu+9RixdPOs/HAoGBANCM + pmpkMkkAUQFzDBghE2QXRh9I7taIM32SAo/xz0dVfrVAFDYpKsQ127xghJNwmCy8 + SFJ/rOC/ULvDZXXzmvfngolA170T6QjRlaLlqX9e+F4EzDvB0C9BEGs3Ha+byZwV + Y/kcCbIhV5j3N7zpXxFmkW4HmjiWN8t9Mlgeb3+nAoGACHZMdRDb49iOk1bNNkev + 6O2FqN5BMuYvqZrprwZ7YYVYutns68g1eS4hNXavTy/biT3GtHhk1CC2bmUrYNQC + MzAaVNtPhnMiSx+xGzTmRK7GSuskuTW2rQ+1TXfBT51hLHwAjlXloE46yQr8wI3N + xPDpACBeJYII7iaqfyQ6tGMCgYBCzZsNH3VgHwLTxQeNvyKYAECNCu6+t7hOs/Ow + KlQsVH2XD6SpyLwTR/FQQVaWaA3G3rUIAC/fekkhLDEW/GaanIUa9DNnNLaEBaa6 + HHkT/NbwPvcw+R93046v2WLf+rY1EkEI7etJLRcDP8WR9OtoBoP1S+gh0jSjMUJs + KaurpwKBgCUUXwwsSkRMe/4rlabVZM5H12sT3diQPaYcDCdQZa1sIl0qHwDzzYcy + M4FHekELoBUO+SI7Okz9icDJiyjg0H8/5jYqSNZG2ezSw1o+Jbu0HYj2BqkqpGUf + Q6K1e9bCewMlfCzCSs6KdISPnLMA91tUJb2KLr45B1FONc/3pEg3 + -----END RSA PRIVATE KEY----- + PRIVATE_KEY + ; + const PUBLIC_KEY= <<<'PUBLIC_KEY' + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsxFFSy1nXqchsH5sNr7v + 0bYrP5KAlKUPZxa2rNCD0uxsYy2+K9XFTCU2uQJFpkBkiUMUdYTfSoI7lqUkgYf2 + X30S8Bc9hV0C656yK7kA00EOZrkDgpooJwGjDd9R60baHdSYnMh9EIiCB8XR8C22 + Ha3Uso0v3EsDvor5k3HzN8dkBJkZs9fJqf/K5P22HDuC/EhIDxdrODhwhoshpydN + +FHwt8V4uuhULOvj8mFW0ooUUuyYT/qWtjOmV0KhIZmtIyQBwe62SKxNQttdJ6wq + 55gCTmWIWw26Q4x/Q8bcHgF13yEa9NcG6AWHiU3ln7tqqplPApk1erKWjdXERbpD + 0QIDAQAB + -----END PUBLIC KEY----- + PUBLIC_KEY + ; + + /** @return iterable */ + private function malformed() { + yield ['', '/Expected \[header\].\[payload\].\[signature\]/']; + yield ['a.b', '/Expected \[header\].\[payload\].\[signature\]/']; + yield ['a.b.c', '/Header parsing error/']; + yield ['e30.b.c', '/Unsupported algorithm \(null\)/']; + yield ['eyJhbGciOiJSUzI1NiJ9.b.c', '/Payload parsing error/']; + yield ['eyJhbGciOiJIUzI1NiJ9.b.c', '/Unsupported algorithm HS256/']; + yield ['eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.e30.c', '/Signature mismatch/']; + } + + #[Test, Values([['Test', 'VGVzdA'], ["\xfb", '-w'], ["\xff", '_w']])] + public function encode($input, $encoded) { + Assert::equals($encoded, JWT::encode($input)); + } + + #[Test, Values([['VGVzdA', 'Test'], ['-w', "\xfb"], ['_w', "\xff"]])] + public function decode($input, $decoded) { + Assert::equals($decoded, JWT::decode($input)); + } + + #[Test] + public function header() { + Assert::equals(self::HEADER, (new JWT(self::HEADER, []))->header()); + } + + #[Test] + public function alg_defaults_to_RS256() { + Assert::equals(self::HEADER, (new JWT(['typ' => 'JWT'], []))->header()); + } + + #[Test] + public function payload() { + $payload= ['iss' => self::ISSUER, 'sub' => 'test']; + Assert::equals($payload, (new JWT(self::HEADER, $payload))->payload()); + } + + #[Test, Values([['iat', 6100], ['name', 'Test'], ['loggedInAs', null]])] + public function claim($name, $expected) { + Assert::equals($expected, (new JWT(self::HEADER, ['iat' => 6100, 'name' => 'Test']))->claim($name)); + } + + #[Test] + public function sign() { + $jwt= new JWT(self::HEADER, ['iss' => self::ISSUER, 'sub' => 'test']); + Assert::equals(implode('', self::TOKEN), $jwt->sign(self::PRIVATE_KEY)); + } + + #[Test] + public function from() { + Assert::equals( + new JWT(self::HEADER, ['iss' => self::ISSUER, 'sub' => 'test']), + JWT::from(implode('', self::TOKEN), self::PUBLIC_KEY) + ); + } + + #[Test] + public function try_from() { + Assert::equals( + new JWT(self::HEADER, ['iss' => self::ISSUER, 'sub' => 'test']), + JWT::tryFrom(implode('', self::TOKEN), self::PUBLIC_KEY) + ); + } + + #[Test, Values(from: 'malformed')] + public function from_malformed($token, $error) { + try { + JWT::from($token, self::PUBLIC_KEY); + Assert::throws(IllegalStateException::class, fn() => null); + } catch (IllegalStateException $expected) { + Assert::matches($error, $expected->getMessage()); + } + } + + #[Test, Values(from: 'malformed')] + public function try_from_malformed($token, $error) { + Assert::null(JWT::tryFrom($token, self::PUBLIC_KEY)); + } +} \ No newline at end of file From 9bee35df7348f5612c22bca4ecae8f03bd20fc13 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 31 Dec 2025 23:10:31 +0100 Subject: [PATCH 2/3] Handle `tryFrom|from(null, $publicKey)` gracefully --- src/main/php/web/auth/oauth/JWT.class.php | 12 ++++++------ src/test/php/web/auth/unittest/JWTTest.class.php | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/php/web/auth/oauth/JWT.class.php b/src/main/php/web/auth/oauth/JWT.class.php index e46ee41..9d2ac28 100644 --- a/src/main/php/web/auth/oauth/JWT.class.php +++ b/src/main/php/web/auth/oauth/JWT.class.php @@ -94,13 +94,13 @@ private static function parse($token, $publicKey) { /** * Parse token into a JWT, verifying its signature with the public key * - * @param string $token + * @param ?string $token * @param string|OpenSSLAsymmetricKey $publicKey * @return self * @throws lang.IllegalStateException if verification fails */ - public static function from(string $token, $publicKey): self { - [$jwt, $err]= self::parse($token, $publicKey); + public static function from(?string $token, $publicKey): self { + [$jwt, $err]= self::parse($token ?? '', $publicKey); // TODO (PHP 8): Migrate to throw expressions return $jwt ?? (function() use($err) { throw new IllegalStateException($err); })(); @@ -109,12 +109,12 @@ public static function from(string $token, $publicKey): self { /** * Try to parse token into a JWT and verify its signature with the public key * - * @param string $token + * @param ?string $token * @param string|OpenSSLAsymmetricKey $publicKey * @return ?self */ - public static function tryFrom(string $token, $publicKey): ?self { - [$jwt, $err]= self::parse($token, $publicKey); + public static function tryFrom(?string $token, $publicKey): ?self { + [$jwt, $err]= self::parse($token ?? '', $publicKey); return $jwt; } } \ No newline at end of file diff --git a/src/test/php/web/auth/unittest/JWTTest.class.php b/src/test/php/web/auth/unittest/JWTTest.class.php index c8461ad..0bdc984 100755 --- a/src/test/php/web/auth/unittest/JWTTest.class.php +++ b/src/test/php/web/auth/unittest/JWTTest.class.php @@ -61,6 +61,7 @@ class JWTTest { /** @return iterable */ private function malformed() { + yield [null, '/Expected \[header\].\[payload\].\[signature\]/']; yield ['', '/Expected \[header\].\[payload\].\[signature\]/']; yield ['a.b', '/Expected \[header\].\[payload\].\[signature\]/']; yield ['a.b.c', '/Header parsing error/']; From 85dc207c5bf0dd7f055ac79885884073280cc249 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 31 Dec 2025 23:21:19 +0100 Subject: [PATCH 3/3] Extract common string into class constant --- src/main/php/web/auth/oauth/JWT.class.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/php/web/auth/oauth/JWT.class.php b/src/main/php/web/auth/oauth/JWT.class.php index 9d2ac28..8514a2b 100644 --- a/src/main/php/web/auth/oauth/JWT.class.php +++ b/src/main/php/web/auth/oauth/JWT.class.php @@ -11,11 +11,13 @@ * @ext openssl */ class JWT { + const ALG= 'RS256'; + private $header, $payload; /** Creates a new JWT with a given header and payload */ public function __construct(array $header, array $payload) { - $this->header= ['alg' => 'RS256'] + $header; + $this->header= ['alg' => self::ALG] + $header; $this->payload= $payload; } @@ -74,7 +76,7 @@ private static function parse($token, $publicKey) { $header= json_decode(self::decode($parts[0]), true); if (json_last_error()) { return [null, 'Header parsing error: '.json_last_error_msg()]; - } else if ('RS256' !== ($alg= $header['alg'] ?? '(null)')) { + } else if (self::ALG !== ($alg= $header['alg'] ?? '(null)')) { return [null, 'Unsupported algorithm '.$alg]; }