diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7579f74
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+vendor
+composer.lock
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..3b82fe5
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,14 @@
+language: php
+sudo: false
+
+php:
+ - 5.6
+ - 7.0
+ - 7.1
+
+before_script:
+ - composer install -n
+
+script:
+ - ./vendor/bin/phpcs --standard=psr2 . --ignore=/vendor/
+ - ./vendor/bin/phpunit
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..ab9b4c0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (C) 2016 Angry Bytes
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4e76b8f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# portier-php
+
+A [Portier] client library for PHP
+
+ [Portier]: https://portier.github.io/
+
+### Example
+
+```php
+pconnect('127.0.0.1', 6379);
+
+$portier = new \Portier\Client\Client(
+ new \Portier\Client\RedisStore($redis),
+ 'http://localhost:8000/verify'
+);
+
+$app->get('/', function($req, $res) {
+ $res = $res
+ ->withStatus(200)
+ ->withHeader('Content-Type', 'text/html; charset=utf-8');
+
+ $res->getBody()->write(
+<<Enter your email address:
+
+EOF
+ );
+
+ return $res;
+});
+
+$app->post('/auth', function($req, $res) use ($portier) {
+ $authUrl = $portier->authenticate($req->getParsedBodyParam('email'));
+
+ return $res
+ ->withStatus(303)
+ ->withHeader('Location', $authUrl);
+});
+
+$app->post('/verify', function($req, $res) use ($portier) {
+ $email = $portier->verify($req->getParsedBodyParam('id_token'));
+
+ $res = $res
+ ->withStatus(200)
+ ->withHeader('Content-Type', 'text/html; charset=utf-8');
+
+ $res->getBody()->write(
+<<Verified email address ${email}!
+EOF
+ );
+
+ return $res;
+});
+
+$app->run();
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..b01b39c
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,27 @@
+{
+ "name": "portier/client",
+ "description": "Portier client for PHP",
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Stéphan Kochen",
+ "email": "stephan@kochen.nl"
+ }
+ ],
+ "autoload": {
+ "psr-4": {
+ "Portier\\Client\\": "src/"
+ }
+ },
+ "require": {
+ "spomky-labs/base64url": "^1.0",
+ "fgrosse/phpasn1": "^1.5",
+ "lcobucci/jwt": "^3.2",
+ "guzzlehttp/guzzle": "^6.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^5.6",
+ "squizlabs/php_codesniffer": "^2.7"
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..a803f26
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ tests/
+
+
+
diff --git a/src/AbstractStore.php b/src/AbstractStore.php
new file mode 100644
index 0000000..67d1dd5
--- /dev/null
+++ b/src/AbstractStore.php
@@ -0,0 +1,81 @@
+guzzle = new \GuzzleHttp\Client([
+ 'timeout' => 10
+ ]);
+ }
+
+ /**
+ * Generate a nonce value.
+ * @param string $email Optional email context
+ * @return string The generated nonce.
+ */
+ public function generateNonce($email)
+ {
+ return bin2hex(random_bytes(16));
+ }
+
+ /**
+ * Fetch a URL using HTTP GET.
+ * @param string $url The URL to fetch.
+ * @return object An object with `ttl` and `data` properties.
+ */
+ public function fetch($url)
+ {
+ $res = $this->guzzle->get($url);
+
+ $data = json_decode($res->getBody());
+ if (!($data instanceof \stdClass)) {
+ throw new \Exception('Invalid response body');
+ }
+
+ $ttl = 0;
+ if ($res->hasHeader('Cache-Control')) {
+ if (preg_match(
+ '/max-age\s*=\s*(\d+)/',
+ $res->getHeader('Cache-Control'),
+ $matches
+ )) {
+ $ttl = intval($matches[1]);
+ }
+ }
+ $ttl = max($this->cacheMinTtl, $ttl);
+
+ return (object) [
+ 'ttl' => $ttl,
+ 'data' => $data,
+ ];
+ }
+}
diff --git a/src/Client.php b/src/Client.php
new file mode 100644
index 0000000..55b189f
--- /dev/null
+++ b/src/Client.php
@@ -0,0 +1,180 @@
+store = $store;
+ $this->redirectUri = $redirectUri;
+
+ $this->clientId = self::getOrigin($this->redirectUri);
+ }
+
+ /**
+ * Start authentication of an email address.
+ * @param string $email Email address to authenticate.
+ * @return string URL to redirect the browser to.
+ */
+ public function authenticate($email)
+ {
+ $nonce = $this->store->createNonce($email);
+ $query = http_build_query([
+ 'login_hint' => $email,
+ 'scope' => 'openid email',
+ 'nonce' => $nonce,
+ 'response_type' => 'id_token',
+ 'response_mode' => 'form_post',
+ 'client_id' => $this->clientId,
+ 'redirect_uri' => $this->redirectUri,
+ ]);
+ return $this->broker . '/auth?' . $query;
+ }
+
+ /**
+ * Verify a token received on our `redirect_uri`.
+ * @param string $token The received `id_token` parameter value.
+ * @return string The verified email address.
+ */
+ public function verify($token)
+ {
+ // Parse token and get the key ID from its header.
+ $parser = new \Lcobucci\JWT\Parser();
+ $token = $parser->parse($token);
+ $kid = $token->getHeader('kid');
+
+ // Fetch broker keys.
+ $discoveryUrl = $this->broker . '/.well-known/openid-configuration';
+ $discoveryDoc = $this->store->fetchCached('discovery', $discoveryUrl);
+ if (!isset($discoveryDoc->jwks_uri) || !is_string($discoveryDoc->jwks_uri)) {
+ throw new \Exception('Discovery document incorrectly formatted');
+ }
+
+ $keysDoc = $this->store->fetchCached('keys', $discoveryDoc->jwks_uri);
+ if (!isset($keysDoc->keys) || !is_array($keysDoc->keys)) {
+ throw new \Exception('Keys document incorrectly formatted');
+ }
+
+ // Find the matching public key, and verify the signature.
+ $publicKey = null;
+ foreach ($keysDoc->keys as $key) {
+ if (isset($key->alg) && $key->alg === 'RS256' &&
+ isset($key->kid) && $key->kid === $kid &&
+ isset($key->n) && isset($key->e)) {
+ $publicKey = $key;
+ break;
+ }
+ }
+ if ($publicKey === null) {
+ throw new \Exception('Cannot find the public key used to sign the token');
+ }
+ if (!$token->verify(
+ new \Lcobucci\JWT\Signer\Rsa\Sha256(),
+ self::parseJwk($publicKey)
+ )) {
+ throw new \Exception('Token signature did not validate');
+ }
+
+ // Validate the token claims.
+ $vdata = new \Lcobucci\JWT\ValidationData();
+ $vdata->setIssuer($this->broker);
+ $vdata->setAudience($this->clientId);
+ if (!$token->validate($vdata)) {
+ throw new \Exception('Token claims did not validate');
+ }
+
+ // Get the email and consume the nonce.
+ $nonce = $token->getClaim('nonce');
+ $email = $token->getClaim('sub');
+ $this->store->consumeNonce($nonce, $email);
+
+ return $email;
+ }
+
+ /**
+ * Parse a JWK into an OpenSSL public key.
+ * @param object $jwk
+ * @return resource
+ */
+ private static function parseJwk($jwk)
+ {
+ $n = gmp_init(bin2hex(\Base64Url\Base64Url::decode($jwk->n)), 16);
+ $e = gmp_init(bin2hex(\Base64Url\Base64Url::decode($jwk->e)), 16);
+
+ $seq = new \FG\ASN1\Universal\Sequence();
+ $seq->addChild(new \FG\ASN1\Universal\Integer(gmp_strval($n)));
+ $seq->addChild(new \FG\ASN1\Universal\Integer(gmp_strval($e)));
+ $pkey = new \FG\X509\PublicKey(bin2hex($seq->getBinary()));
+
+ return new \Lcobucci\JWT\Signer\Key(
+ "-----BEGIN PUBLIC KEY-----\n" .
+ base64_encode($pkey->getBinary()) . "\n" .
+ "-----END PUBLIC KEY-----\n"
+ );
+ }
+
+ /**
+ * Get the origin for a URL
+ * @param string $url
+ * @return string
+ */
+ private static function getOrigin($url)
+ {
+ $components = parse_url($url);
+ if ($components === false) {
+ throw new \Exception('Could not parse the redirect URI');
+ }
+
+ if (!isset($components['scheme'])) {
+ throw new \Exception('No scheme set in redirect URI');
+ }
+ $scheme = $components['scheme'];
+
+ if (!isset($components['host'])) {
+ throw new \Exception('No host set in redirect URI');
+ }
+ $host = $components['host'];
+
+ $res = $scheme . '://' . $host;
+ if (isset($components['port'])) {
+ $port = $components['port'];
+ if (($scheme === 'http' && $port !== 80) ||
+ ($scheme === 'https' && $port !== 443)) {
+ $res .= ':' . $port;
+ }
+ }
+
+ return $res;
+ }
+}
diff --git a/src/RedisStore.php b/src/RedisStore.php
new file mode 100644
index 0000000..df9f6e1
--- /dev/null
+++ b/src/RedisStore.php
@@ -0,0 +1,68 @@
+redis = $redis;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function fetchCached($cacheId, $url)
+ {
+ $key = 'cache:' . $cacheId;
+
+ $data = $this->redis->get($key);
+ if ($data) {
+ return json_decode($data);
+ }
+
+ $res = $this->fetch($url);
+ $this->redis->setEx($key, $res->ttl, json_encode($res->data));
+
+ return $res->data;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function createNonce($email)
+ {
+ $nonce = $this->generateNonce($email);
+
+ $key = 'nonce:' . $nonce;
+ $this->redis->setEx($key, $this->nonceTtl, $email);
+
+ return $nonce;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function consumeNonce($nonce, $email)
+ {
+ $key = 'nonce:' . $nonce;
+ $res = $this->redis->multi()
+ ->get($key)
+ ->del($key)
+ ->exec();
+ if ($res[0] !== $email) {
+ throw new \Exception('Invalid or expired nonce');
+ }
+ }
+}
diff --git a/src/StoreInterface.php b/src/StoreInterface.php
new file mode 100644
index 0000000..87e7bef
--- /dev/null
+++ b/src/StoreInterface.php
@@ -0,0 +1,31 @@
+markTestIncomplete();
+ }
+
+ public function testFetch()
+ {
+ $this->markTestIncomplete();
+ }
+}
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
new file mode 100644
index 0000000..29e1936
--- /dev/null
+++ b/tests/ClientTest.php
@@ -0,0 +1,37 @@
+prophesize(\Portier\Client\StoreInterface::class);
+ $store->createNonce('johndoe@example.com')
+ ->willReturn('foobar')
+ ->shouldBeCalled();
+
+ $client = new \Portier\Client\Client(
+ $store->reveal(),
+ 'https://example.com/callback'
+ );
+
+ $this->assertEquals(
+ $client->authenticate('johndoe@example.com'),
+ 'https://broker.portier.io/auth?' . http_build_query([
+ 'login_hint' => 'johndoe@example.com',
+ 'scope' => 'openid email',
+ 'nonce' => 'foobar',
+ 'response_type' => 'id_token',
+ 'response_mode' => 'form_post',
+ 'client_id' => 'https://example.com',
+ 'redirect_uri' => 'https://example.com/callback',
+ ])
+ );
+ }
+
+ public function testVerify()
+ {
+ $this->markTestIncomplete();
+ }
+}
diff --git a/tests/RedisStoreTest.php b/tests/RedisStoreTest.php
new file mode 100644
index 0000000..a32d604
--- /dev/null
+++ b/tests/RedisStoreTest.php
@@ -0,0 +1,21 @@
+markTestIncomplete();
+ }
+
+ public function testCreateNonce()
+ {
+ $this->markTestIncomplete();
+ }
+
+ public function testConsumeNonce()
+ {
+ $this->markTestIncomplete();
+ }
+}