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(); + } +}