diff --git a/.travis.yml b/.travis.yml index cdad54a..7c2be99 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,9 +2,9 @@ language: php sudo: false php: - - 7.0 - 7.1 - 7.2 + - 7.3 before_install: - echo 'extension = redis.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini diff --git a/composer.json b/composer.json index 6a4c99e..d4990e1 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,8 @@ "guzzlehttp/guzzle": "^6.2" }, "require-dev": { - "phpunit/phpunit": "^6.3", - "phpstan/phpstan": "^0.9", + "phpunit/phpunit": "^7.5", + "phpstan/phpstan": "^0.11", "squizlabs/php_codesniffer": "^3.1" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b2d885a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + ignoreErrors: + # Invalid type hint + - '|^Parameter #1 \$value of class FG\\ASN1\\Universal\\Integer constructor expects int, string given\.$|' diff --git a/phpunit.xml b/phpunit.xml index a803f26..9b479eb 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ - + tests/ diff --git a/src/Client.php b/src/Client.php index 95f079d..8612c32 100644 --- a/src/Client.php +++ b/src/Client.php @@ -11,7 +11,9 @@ class Client * Default Portier broker origin. * @var string */ - const DEFAULT_BROKER = 'https://broker.portier.io'; + public const DEFAULT_BROKER = 'https://broker.portier.io'; + + private const REQUIRED_CLAIMS = ['iss', 'aud', 'exp', 'iat', 'email', 'nonce']; private $store; private $redirectUri; @@ -50,19 +52,79 @@ public function __construct(StoreInterface $store, string $redirectUri) * `authenticate`, normalization is already part of the authentication * process. * - * This is currently implemented by making an HTTP call to Portier, without - * cache. + * For PHP 7.3 with the intl extension, this function can process the email + * list locally. Otherwise, note that this function makes an HTTP call to + * the Portier broker, without result caching. + * + * Use `hasNormalizeLocal` to check if local normalization is available at + * run-time, or directly use `normalizeLocal` to force-or-fail local + * normalization. * * @param string[] $emails Email addresses to normalize. * @return string[] Normalized email addresses, empty strings for invalid. */ public function normalize(array $emails): array { - $res = $this->store->guzzle->post( - $this->broker . '/normalize', - ['body' => implode("\n", $emails)] + if (self::hasNormalizeLocal()) { + return array_map([self::class, 'normalizeLocal'], $emails); + } else { + $res = $this->store->guzzle->post( + $this->broker . '/normalize', + ['body' => implode("\n", $emails)] + ); + return explode("\n", (string) $res->getBody()); + } + } + + /** + * Normalize an email address. (Pure-PHP version) + * + * This method is useful when comparing user input to an email address + * returned in a Portier token. It is not necessary to call this before + * `authenticate`, normalization is already part of the authentication + * process. + * + * This function requires PHP 7.3 with the intl extension. + */ + public static function normalizeLocal(string $email): string + { + // Repeat these checks here, so PHPStan understands. + assert(defined('MB_CASE_FOLD') && function_exists('idn_to_ascii')); + + $localEnd = strrpos($email, '@'); + if ($localEnd === false) { + return ''; + } + + $local = mb_convert_case( + substr($email, 0, $localEnd), + MB_CASE_FOLD + ); + if (empty($local)) { + return ''; + } + + $host = idn_to_ascii( + substr($email, $localEnd + 1), + IDNA_USE_STD3_RULES | IDNA_CHECK_BIDI, + INTL_IDNA_VARIANT_UTS46 ); - return explode("\n", (string) $res->getBody()); + if (empty($host) || $host[0] === '[' || + filter_var($host, FILTER_VALIDATE_IP) !== false) { + return ''; + } + + return sprintf('%s@%s', $local, $host); + } + + /** + * Check whether `normalizeLocal` can be used on this PHP installation. + * + * The `normalizeLocal` function requires PHP 7.3 with the intl extension. + */ + public static function hasNormalizeLocal(): bool + { + return defined('MB_CASE_FOLD') && function_exists('idn_to_ascii'); } /** @@ -129,6 +191,14 @@ public function verify(string $token): string throw new \Exception('Token signature did not validate'); } + // Check that the required token claims are set. + $missing = array_filter(self::REQUIRED_CLAIMS, function (string $name) use ($token) { + return !$token->hasClaim($name); + }); + if (!empty($missing)) { + throw new \Exception(sprintf('Token is missing claims: %s', implode(', ', $missing))); + } + // Validate the token claims. $vdata = new \Lcobucci\JWT\ValidationData(); $vdata->setIssuer($this->broker); @@ -137,11 +207,13 @@ public function verify(string $token): string throw new \Exception('Token claims did not validate'); } - // Get the email and consume the nonce. + // Consume the nonce. $nonce = $token->getClaim('nonce'); - $email = $token->getClaim('sub'); - $this->store->consumeNonce($nonce, $email); + $email = $token->getClaim('email'); + $emailOriginal = $token->getClaim('email_original', $email); + $this->store->consumeNonce($nonce, $emailOriginal); + // Return the normalized email. return $email; } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 29e1936..ee9d330 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -2,16 +2,49 @@ namespace Tests; +use \Portier\Client; + class ClientTest extends \PHPUnit\Framework\TestCase { + public function testLocalNormalize() + { + if (!Client\Client::hasNormalizeLocal()) { + return; + } + + $valid = [ + ['example.foo+bar@example.com', 'example.foo+bar@example.com'], + ['EXAMPLE.FOO+BAR@EXAMPLE.COM', 'example.foo+bar@example.com'], + // Simple case transformation + ['BJÖRN@göteborg.test', 'björn@xn--gteborg-90a.test'], + // Special case transformation + ['İⅢ@İⅢ.example', 'i̇ⅲ@xn--iiii-qwc.example'], + ]; + foreach ($valid as $pair) { + list($i, $o) = $pair; + $this->assertEquals(Client\Client::normalizeLocal($i), $o); + } + + $invalid = [ + 'foo', + 'foo@', + '@foo.example', + 'foo@127.0.0.1', + 'foo@[::1]', + ]; + foreach ($invalid as $i) { + $this->assertEquals(Client\Client::normalizeLocal($i), ''); + } + } + public function testAuthenticate() { - $store = $this->prophesize(\Portier\Client\StoreInterface::class); + $store = $this->prophesize(Client\StoreInterface::class); $store->createNonce('johndoe@example.com') ->willReturn('foobar') ->shouldBeCalled(); - $client = new \Portier\Client\Client( + $client = new Client\Client( $store->reveal(), 'https://example.com/callback' );