Skip to content

Commit

Permalink
Merge pull request #6 from portier/feat-normalize-local
Browse files Browse the repository at this point in the history
Local implementation of normalization
  • Loading branch information
Stéphan Kochen authored Jul 16, 2019
2 parents dcb7e07 + 89157ae commit 96e7650
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 16 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
4 changes: 4 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
parameters:
ignoreErrors:
# Invalid type hint
- '|^Parameter #1 \$value of class FG\\ASN1\\Universal\\Integer constructor expects int, string given\.$|'
2 changes: 1 addition & 1 deletion phpunit.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite>
<testsuite name="portier">
<directory>tests/</directory>
</testsuite>
</testsuites>
Expand Down
92 changes: 82 additions & 10 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

Expand Down
37 changes: 35 additions & 2 deletions tests/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,49 @@

namespace Tests;

use \Portier\Client;

class ClientTest extends \PHPUnit\Framework\TestCase
{
public function testLocalNormalize()
{
if (!Client\Client::hasNormalizeLocal()) {
return;
}

$valid = [
['[email protected]', '[email protected]'],
['[email protected]', '[email protected]'],
// Simple case transformation
['BJÖRN@göteborg.test', 'bjö[email protected]'],
// 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',
'[email protected]',
'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('[email protected]')
->willReturn('foobar')
->shouldBeCalled();

$client = new \Portier\Client\Client(
$client = new Client\Client(
$store->reveal(),
'https://example.com/callback'
);
Expand Down

0 comments on commit 96e7650

Please sign in to comment.