Skip to content

Commit 96e7650

Browse files
author
Stéphan Kochen
authored
Merge pull request #6 from portier/feat-normalize-local
Local implementation of normalization
2 parents dcb7e07 + 89157ae commit 96e7650

File tree

6 files changed

+125
-16
lines changed

6 files changed

+125
-16
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ language: php
22
sudo: false
33

44
php:
5-
- 7.0
65
- 7.1
76
- 7.2
7+
- 7.3
88

99
before_install:
1010
- echo 'extension = redis.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
"guzzlehttp/guzzle": "^6.2"
2222
},
2323
"require-dev": {
24-
"phpunit/phpunit": "^6.3",
25-
"phpstan/phpstan": "^0.9",
24+
"phpunit/phpunit": "^7.5",
25+
"phpstan/phpstan": "^0.11",
2626
"squizlabs/php_codesniffer": "^3.1"
2727
}
2828
}

phpstan.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
parameters:
2+
ignoreErrors:
3+
# Invalid type hint
4+
- '|^Parameter #1 \$value of class FG\\ASN1\\Universal\\Integer constructor expects int, string given\.$|'

phpunit.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<phpunit bootstrap="vendor/autoload.php">
33
<testsuites>
4-
<testsuite>
4+
<testsuite name="portier">
55
<directory>tests/</directory>
66
</testsuite>
77
</testsuites>

src/Client.php

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ class Client
1111
* Default Portier broker origin.
1212
* @var string
1313
*/
14-
const DEFAULT_BROKER = 'https://broker.portier.io';
14+
public const DEFAULT_BROKER = 'https://broker.portier.io';
15+
16+
private const REQUIRED_CLAIMS = ['iss', 'aud', 'exp', 'iat', 'email', 'nonce'];
1517

1618
private $store;
1719
private $redirectUri;
@@ -50,19 +52,79 @@ public function __construct(StoreInterface $store, string $redirectUri)
5052
* `authenticate`, normalization is already part of the authentication
5153
* process.
5254
*
53-
* This is currently implemented by making an HTTP call to Portier, without
54-
* cache.
55+
* For PHP 7.3 with the intl extension, this function can process the email
56+
* list locally. Otherwise, note that this function makes an HTTP call to
57+
* the Portier broker, without result caching.
58+
*
59+
* Use `hasNormalizeLocal` to check if local normalization is available at
60+
* run-time, or directly use `normalizeLocal` to force-or-fail local
61+
* normalization.
5562
*
5663
* @param string[] $emails Email addresses to normalize.
5764
* @return string[] Normalized email addresses, empty strings for invalid.
5865
*/
5966
public function normalize(array $emails): array
6067
{
61-
$res = $this->store->guzzle->post(
62-
$this->broker . '/normalize',
63-
['body' => implode("\n", $emails)]
68+
if (self::hasNormalizeLocal()) {
69+
return array_map([self::class, 'normalizeLocal'], $emails);
70+
} else {
71+
$res = $this->store->guzzle->post(
72+
$this->broker . '/normalize',
73+
['body' => implode("\n", $emails)]
74+
);
75+
return explode("\n", (string) $res->getBody());
76+
}
77+
}
78+
79+
/**
80+
* Normalize an email address. (Pure-PHP version)
81+
*
82+
* This method is useful when comparing user input to an email address
83+
* returned in a Portier token. It is not necessary to call this before
84+
* `authenticate`, normalization is already part of the authentication
85+
* process.
86+
*
87+
* This function requires PHP 7.3 with the intl extension.
88+
*/
89+
public static function normalizeLocal(string $email): string
90+
{
91+
// Repeat these checks here, so PHPStan understands.
92+
assert(defined('MB_CASE_FOLD') && function_exists('idn_to_ascii'));
93+
94+
$localEnd = strrpos($email, '@');
95+
if ($localEnd === false) {
96+
return '';
97+
}
98+
99+
$local = mb_convert_case(
100+
substr($email, 0, $localEnd),
101+
MB_CASE_FOLD
102+
);
103+
if (empty($local)) {
104+
return '';
105+
}
106+
107+
$host = idn_to_ascii(
108+
substr($email, $localEnd + 1),
109+
IDNA_USE_STD3_RULES | IDNA_CHECK_BIDI,
110+
INTL_IDNA_VARIANT_UTS46
64111
);
65-
return explode("\n", (string) $res->getBody());
112+
if (empty($host) || $host[0] === '[' ||
113+
filter_var($host, FILTER_VALIDATE_IP) !== false) {
114+
return '';
115+
}
116+
117+
return sprintf('%s@%s', $local, $host);
118+
}
119+
120+
/**
121+
* Check whether `normalizeLocal` can be used on this PHP installation.
122+
*
123+
* The `normalizeLocal` function requires PHP 7.3 with the intl extension.
124+
*/
125+
public static function hasNormalizeLocal(): bool
126+
{
127+
return defined('MB_CASE_FOLD') && function_exists('idn_to_ascii');
66128
}
67129

68130
/**
@@ -129,6 +191,14 @@ public function verify(string $token): string
129191
throw new \Exception('Token signature did not validate');
130192
}
131193

194+
// Check that the required token claims are set.
195+
$missing = array_filter(self::REQUIRED_CLAIMS, function (string $name) use ($token) {
196+
return !$token->hasClaim($name);
197+
});
198+
if (!empty($missing)) {
199+
throw new \Exception(sprintf('Token is missing claims: %s', implode(', ', $missing)));
200+
}
201+
132202
// Validate the token claims.
133203
$vdata = new \Lcobucci\JWT\ValidationData();
134204
$vdata->setIssuer($this->broker);
@@ -137,11 +207,13 @@ public function verify(string $token): string
137207
throw new \Exception('Token claims did not validate');
138208
}
139209

140-
// Get the email and consume the nonce.
210+
// Consume the nonce.
141211
$nonce = $token->getClaim('nonce');
142-
$email = $token->getClaim('sub');
143-
$this->store->consumeNonce($nonce, $email);
212+
$email = $token->getClaim('email');
213+
$emailOriginal = $token->getClaim('email_original', $email);
214+
$this->store->consumeNonce($nonce, $emailOriginal);
144215

216+
// Return the normalized email.
145217
return $email;
146218
}
147219

tests/ClientTest.php

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,49 @@
22

33
namespace Tests;
44

5+
use \Portier\Client;
6+
57
class ClientTest extends \PHPUnit\Framework\TestCase
68
{
9+
public function testLocalNormalize()
10+
{
11+
if (!Client\Client::hasNormalizeLocal()) {
12+
return;
13+
}
14+
15+
$valid = [
16+
['example.foo+bar@example.com', 'example.foo+bar@example.com'],
17+
['EXAMPLE.FOO+BAR@EXAMPLE.COM', 'example.foo+bar@example.com'],
18+
// Simple case transformation
19+
['BJÖRN@göteborg.test', 'björn@xn--gteborg-90a.test'],
20+
// Special case transformation
21+
['İⅢ@İⅢ.example', 'i̇ⅲ@xn--iiii-qwc.example'],
22+
];
23+
foreach ($valid as $pair) {
24+
list($i, $o) = $pair;
25+
$this->assertEquals(Client\Client::normalizeLocal($i), $o);
26+
}
27+
28+
$invalid = [
29+
'foo',
30+
'foo@',
31+
'@foo.example',
32+
'foo@127.0.0.1',
33+
'foo@[::1]',
34+
];
35+
foreach ($invalid as $i) {
36+
$this->assertEquals(Client\Client::normalizeLocal($i), '');
37+
}
38+
}
39+
740
public function testAuthenticate()
841
{
9-
$store = $this->prophesize(\Portier\Client\StoreInterface::class);
42+
$store = $this->prophesize(Client\StoreInterface::class);
1043
$store->createNonce('johndoe@example.com')
1144
->willReturn('foobar')
1245
->shouldBeCalled();
1346

14-
$client = new \Portier\Client\Client(
47+
$client = new Client\Client(
1548
$store->reveal(),
1649
'https://example.com/callback'
1750
);

0 commit comments

Comments
 (0)