Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/main/php/web/auth/oauth/ByCertificate.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
95 changes: 86 additions & 9 deletions src/main/php/web/auth/oauth/JWT.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,116 @@
*
* @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 {
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= $header;
$this->header= ['alg' => self::ALG] + $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 (self::ALG !== ($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;
}
}
141 changes: 141 additions & 0 deletions src/test/php/web/auth/unittest/JWTTest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php namespace web\auth\unittest;

use lang\IllegalStateException;
use test\{Assert, Test, Values};
use web\auth\oauth\JWT;

class JWTTest {
const ISSUER= 'xp-testing';
const HEADER= ['alg' => '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 [null, '/Expected \[header\].\[payload\].\[signature\]/'];
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));
}
}
Loading