Skip to content

Commit dc89cc2

Browse files
authored
Merge pull request #10 from portier/feat-testing
Test with client-tester, fix spec incompatibilities
2 parents d7ab597 + 0aaa9c3 commit dc89cc2

File tree

6 files changed

+195
-12
lines changed

6 files changed

+195
-12
lines changed

.editorconfig

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
trim_trailing_whitespace = true
6+
insert_final_newline = true
7+
indent_style = space
8+
indent_size = 2
9+
max_line_length = 80
10+
11+
[*.php]
12+
indent_size = 4
13+
max_line_length = 120

.github/workflows/check.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,22 @@ jobs:
5151

5252
- name: PHPUnit
5353
run: vendor/bin/phpunit --teamcity
54+
55+
- name: Set up Go
56+
uses: actions/setup-go@v2
57+
with:
58+
go-version: ^1.16
59+
60+
- name: Go cache
61+
uses: actions/cache@v2
62+
with:
63+
path: ~/go/pkg/mod
64+
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
65+
restore-keys: |
66+
${{ runner.os }}-go-
67+
68+
- name: Build tester
69+
run: go get -v github.com/portier/client-tester
70+
71+
- name: Run test suite
72+
run: ~/go/bin/client-tester -bin ./client-tester.php

client-tester.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
$argv = $_SERVER['argv'];
5+
if (count($argv) !== 2) {
6+
error_log('Broker required');
7+
exit(1);
8+
}
9+
10+
require_once __DIR__ . '/vendor/autoload.php';
11+
12+
$client = new \Portier\Client\Client(
13+
new \Portier\Client\MemoryStore(),
14+
'http://imaginary-client.test/fake-verify-route'
15+
);
16+
$client->broker = $argv[1];
17+
18+
$stdin = fopen('php://stdin', 'r');
19+
while (($line = fgets($stdin, 4096)) !== false) {
20+
$cmd = explode("\t", trim($line));
21+
switch ($cmd[0]) {
22+
case 'echo':
23+
echo "ok\t{$cmd[1]}\n";
24+
break;
25+
case 'auth':
26+
try {
27+
$authUrl = $client->authenticate($cmd[1]);
28+
echo "ok\t{$authUrl}\n";
29+
} catch (Throwable $err) {
30+
$msg = implode(" ", explode("\n", $err->getMessage()));
31+
echo "err\t{$msg}\n";
32+
}
33+
break;
34+
case 'verify':
35+
try {
36+
$email = $client->verify($cmd[1]);
37+
echo "ok\t{$email}\n";
38+
} catch (Throwable $err) {
39+
$msg = implode(" ", explode("\n", $err->getMessage()));
40+
echo "err\t{$msg}\n";
41+
}
42+
break;
43+
default:
44+
error_log("invalid command: {$cmd[0]}");
45+
exit(1);
46+
}
47+
}
48+
if (!feof($stdin)) {
49+
exit(1);
50+
}

src/Client.php

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ public static function normalize(string $email): string
9494
*/
9595
public function authenticate(string $email): string
9696
{
97+
$authEndpoint = $this->fetchDiscovery()->authorization_endpoint ?? null;
98+
if (!is_string($authEndpoint)) {
99+
throw new \Exception('No authorization_endpoint in discovery document');
100+
}
101+
97102
$nonce = $this->store->createNonce($email);
98103
$query = http_build_query([
99104
'login_hint' => $email,
@@ -104,7 +109,7 @@ public function authenticate(string $email): string
104109
'client_id' => $this->clientId,
105110
'redirect_uri' => $this->redirectUri,
106111
]);
107-
return $this->broker . '/auth?' . $query;
112+
return $authEndpoint . '?' . $query;
108113
}
109114

110115
/**
@@ -126,13 +131,12 @@ public function verify(string $token): string
126131
}
127132

128133
// Fetch broker keys.
129-
$discoveryUrl = $this->broker . '/.well-known/openid-configuration';
130-
$discoveryDoc = $this->store->fetchCached('discovery', $discoveryUrl);
131-
if (!isset($discoveryDoc->jwks_uri) || !is_string($discoveryDoc->jwks_uri)) {
132-
throw new \Exception('Discovery document incorrectly formatted');
134+
$jwksUri = $this->fetchDiscovery()->jwks_uri ?? null;
135+
if (!is_string($jwksUri)) {
136+
throw new \Exception('No jwks_uri in discovery document');
133137
}
134138

135-
$keysDoc = $this->store->fetchCached('keys', $discoveryDoc->jwks_uri);
139+
$keysDoc = $this->store->fetchCached('keys', $jwksUri);
136140
if (!isset($keysDoc->keys) || !is_array($keysDoc->keys)) {
137141
throw new \Exception('Keys document incorrectly formatted');
138142
}
@@ -153,11 +157,13 @@ public function verify(string $token): string
153157
}
154158

155159
// Validate the token claims.
160+
$clock = \Lcobucci\Clock\SystemClock::fromUTC();
161+
$leeway = new \DateInterval('PT' . $this->leeway . 'S');
156162
$constraints = [
157163
new JwtConstraint\SignedWith(new JwtSigner\Rsa\Sha256(), $publicKey),
158164
new JwtConstraint\IssuedBy($this->broker),
159165
new JwtConstraint\PermittedFor($this->clientId),
160-
new JwtConstraint\LooseValidAt(\Lcobucci\Clock\SystemClock::fromUTC()),
166+
new JwtConstraint\LooseValidAt($clock, $leeway),
161167
];
162168
$jwt->validator()->assert($token, ...$constraints);
163169

@@ -180,6 +186,15 @@ public function verify(string $token): string
180186
return $email;
181187
}
182188

189+
/**
190+
* Fetches the OpenID discovery document from the broker.
191+
*/
192+
private function fetchDiscovery(): \stdClass
193+
{
194+
$discoveryUrl = $this->broker . '/.well-known/openid-configuration';
195+
return $this->store->fetchCached('discovery', $discoveryUrl);
196+
}
197+
183198
/**
184199
* Parse a JWK into a PEM public key.
185200
*/

src/MemoryStore.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace Portier\Client;
4+
5+
/**
6+
* A store implementation that keeps everything in-memory.
7+
*
8+
* This will often not work as expected, because PHP clears everything between
9+
* requests. It only exists for testing purposes.
10+
*/
11+
class MemoryStore extends AbstractStore
12+
{
13+
/** @var \stdClass[] */
14+
private $cache;
15+
/** @var \stdClass[] */
16+
private $nonces;
17+
18+
/**
19+
* Constructor
20+
*/
21+
public function __construct()
22+
{
23+
parent::__construct();
24+
25+
$this->cache = [];
26+
$this->nonces = [];
27+
}
28+
29+
/**
30+
* {@inheritDoc}
31+
*/
32+
public function fetchCached(string $cacheId, string $url): \stdClass
33+
{
34+
$item = $this->cache[$cacheId] ?? null;
35+
if ($item !== null && time() < $item->expires) {
36+
return $item->data;
37+
}
38+
39+
$res = $this->fetch($url);
40+
41+
$this->cache[$cacheId] = (object) [
42+
'data' => $res->data,
43+
'expires' => time() + $res->ttl,
44+
];
45+
46+
return $res->data;
47+
}
48+
49+
/**
50+
* {@inheritDoc}
51+
*/
52+
public function createNonce(string $email): string
53+
{
54+
$nonce = $this->generateNonce($email);
55+
56+
$this->nonces[$nonce] = (object) [
57+
'email' => $email,
58+
'expires' => time() + (int) $this->nonceTtl,
59+
];
60+
61+
return $nonce;
62+
}
63+
64+
/**
65+
* {@inheritDoc}
66+
*/
67+
public function consumeNonce(string $nonce, string $email): void
68+
{
69+
$item = $this->nonces[$nonce] ?? null;
70+
if ($item !== null) {
71+
unset($this->nonces[$nonce]);
72+
73+
if ($item->email === $email && time() < $item->expires) {
74+
return;
75+
}
76+
}
77+
78+
throw new \Exception('Invalid or expired nonce');
79+
}
80+
}

tests/ClientTest.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
namespace Tests;
44

5-
use \Portier\Client;
5+
use Portier\Client;
6+
use Prophecy\Argument;
67

78
class ClientTest extends \PHPUnit\Framework\TestCase
89
{
@@ -38,25 +39,30 @@ public function testNormalize()
3839
public function testAuthenticate()
3940
{
4041
$store = $this->prophesize(Client\StoreInterface::class);
42+
$store->fetchCached('discovery', Argument::type('string'))
43+
->willReturn((object) [
44+
'authorization_endpoint' => 'http://imaginary-server.test/auth',
45+
])
46+
->shouldBeCalled();
4147
$store->createNonce('johndoe@example.com')
4248
->willReturn('foobar')
4349
->shouldBeCalled();
4450

4551
$client = new Client\Client(
4652
$store->reveal(),
47-
'https://example.com/callback'
53+
'https://imaginary-client.test/callback'
4854
);
4955

5056
$this->assertEquals(
5157
$client->authenticate('johndoe@example.com'),
52-
'https://broker.portier.io/auth?' . http_build_query([
58+
'http://imaginary-server.test/auth?' . http_build_query([
5359
'login_hint' => 'johndoe@example.com',
5460
'scope' => 'openid email',
5561
'nonce' => 'foobar',
5662
'response_type' => 'id_token',
5763
'response_mode' => 'form_post',
58-
'client_id' => 'https://example.com',
59-
'redirect_uri' => 'https://example.com/callback',
64+
'client_id' => 'https://imaginary-client.test',
65+
'redirect_uri' => 'https://imaginary-client.test/callback',
6066
])
6167
);
6268
}

0 commit comments

Comments
 (0)