Skip to content

Commit 57442af

Browse files
authored
Merge pull request #36 from xp-forge/feature/pkce
Add support for PKCE for OAuth2
2 parents e8796ca + 208d0dc commit 57442af

9 files changed

Lines changed: 173 additions & 27 deletions

File tree

src/main/php/web/auth/oauth/ByCertificate.class.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ public function __construct($clientId, $fingerprint, $privateKey, $validity= 360
3434
}
3535

3636
/** Returns parameters to be used in authentication process */
37-
public function params(string $endpoint, $time= null): array {
38-
$time ?? $time= time();
37+
public function params(string $endpoint, array $seed= []): array {
38+
$time= $seed['time'] ?? time();
3939
$jwt= new JWT(['alg' => 'RS256', 'typ' => 'JWT', 'x5t' => JWT::encode(hex2bin($this->fingerprint))], [
4040
'aud' => $endpoint,
4141
'exp' => $time + $this->validity,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php namespace web\auth\oauth;
2+
3+
use lang\IllegalArgumentException;
4+
5+
/** @test web.auth.unittest.ByPKCETest */
6+
class ByPKCE extends Credentials {
7+
const SUPPORTED= ['S256', 'plain'];
8+
9+
private $challenge, $method;
10+
11+
/**
12+
* Creates credentials with a client ID and method.
13+
* Support the `S256` and `plain` methods.
14+
*
15+
* @param string $clientId
16+
* @param string $method
17+
* @throws lang.IllegalArgumentException
18+
*/
19+
public function __construct($clientId, $method) {
20+
parent::__construct($clientId);
21+
22+
if ('S256' === $method) {
23+
$this->challenge= fn($verifier) => JWT::encode(hash('sha256', $verifier, true));
24+
} else if ('plain' === $method) {
25+
$this->challenge= fn($verifier) => $verifier;
26+
} else {
27+
throw new IllegalArgumentException('Unsupported method '.$method.', expected one of ['.implode(', ', self::SUPPORTED).']');
28+
}
29+
$this->method= $method;
30+
}
31+
32+
/** @return string */
33+
public function method() { return $this->method; }
34+
35+
/** Returns authorization seed */
36+
public function seed(): array {
37+
static $UNRESERVED= 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
38+
39+
$random= random_bytes(64);
40+
$verifier= '';
41+
for ($i= 0; $i < 64; $i++) {
42+
$verifier.= $UNRESERVED[ord($random[$i]) % 66];
43+
}
44+
return ['verifier' => $verifier];
45+
}
46+
47+
/** Returns parameters to be passed on to authorization */
48+
public function pass(array $seed): array {
49+
return [
50+
'code_challenge' => ($this->challenge)($seed['verifier']),
51+
'code_challenge_method' => $this->method,
52+
];
53+
}
54+
55+
/** Returns parameters to be used in authentication process */
56+
public function params(string $endpoint, array $seed= []): array {
57+
return [
58+
'client_id' => $this->key,
59+
'code_verifier' => $seed['verifier'],
60+
];
61+
}
62+
}

src/main/php/web/auth/oauth/BySecret.class.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function __construct($clientId, $secret) {
2020
public function secret() { return $this->secret; }
2121

2222
/** Returns parameters to be used in authentication process */
23-
public function params(string $endpoint, $time= null): array {
23+
public function params(string $endpoint, array $seed= []): array {
2424
return [
2525
'client_id' => $this->key,
2626
'client_secret' => $this->secret->reveal(),

src/main/php/web/auth/oauth/Credentials.class.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ abstract class Credentials {
88

99
static function __static() {
1010
self::$UNSET= new class(null) extends Credentials {
11-
public function params(string $endpoint, $time= null): array {
11+
public function params(string $endpoint, array $seed= []): array {
1212
throw new IllegalStateException('No credentials set');
1313
}
1414
};
@@ -23,6 +23,12 @@ public function __construct($key) {
2323
$this->key= $key;
2424
}
2525

26+
/** Returns authorization seed */
27+
public function seed(): array { return []; }
28+
29+
/** Returns parameters to be passed on to authorization */
30+
public function pass(array $seed): array { return []; }
31+
2632
/** Returns parameters to be used in authentication process */
27-
public abstract function params(string $endpoint, $time= null): array;
33+
public abstract function params(string $endpoint, array $seed= []): array;
2834
}

src/main/php/web/auth/oauth/OAuth2Endpoint.class.php

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<?php namespace web\auth\oauth;
22

3-
use peer\http\HttpConnection;
4-
use lang\IllegalStateException;
53
use io\streams\Streams;
4+
use lang\IllegalStateException;
5+
use peer\http\HttpConnection;
66

77
class OAuth2Endpoint {
88
private $conn, $credentials;
@@ -77,13 +77,26 @@ protected function request($payload) {
7777
}
7878
}
7979

80+
/** @return [:string] */
81+
public function seed() { return $this->credentials->seed(); }
82+
83+
/**
84+
* Returns authorization parameters
85+
*
86+
* @param [:string] $grant
87+
* @param [:string] $seed
88+
* @return [:string]
89+
*/
90+
public function pass($auth, $seed= []) { return $this->credentials->pass($seed) + $auth; }
91+
8092
/**
8193
* Acquires a grant
8294
*
8395
* @param [:string] $grant
96+
* @param [:string] $seed
8497
* @return [:string]
8598
*/
86-
public function acquire($grant) {
87-
return $this->request($this->credentials->params($this->conn->getUrl()->getCanonicalURL()) + $grant);
99+
public function acquire($grant, $seed= []) {
100+
return $this->request($this->credentials->params($this->conn->getUrl()->getCanonicalURL(), $seed) + $grant);
88101
}
89102
}

src/main/php/web/auth/oauth/OAuth2Flow.class.php

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,10 @@ public function authenticate($request, $response, $session) {
110110
$server= $request->param('state');
111111
if (null === $server || null === $stored) {
112112
$state= bin2hex($this->rand->bytes(16));
113+
$seed= $this->backend->seed();
114+
113115
$stored??= ['flow' => []];
114-
$stored['flow'][$state]= (string)$uri;
116+
$stored['flow'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
115117
$session->register($this->namespace, $stored);
116118
$session->transmit($response);
117119

@@ -121,9 +123,9 @@ public function authenticate($request, $response, $session) {
121123
'client_id' => $this->backend->clientId(),
122124
'scope' => implode(' ', $this->scopes),
123125
'redirect_uri' => $callback,
124-
'state' => $state
126+
'state' => $state,
125127
];
126-
$target= $this->auth->using()->params($params)->create();
128+
$target= $this->auth->using()->params($this->backend->pass($params, $seed))->create();
127129

128130
// If a URL fragment is present, append it to the state parameter, which
129131
// is passed as the last parameter to the authentication service.
@@ -150,18 +152,28 @@ public function authenticate($request, $response, $session) {
150152
) {
151153
unset($stored['flow'][$state[0]]);
152154

155+
// Target is an array for old session layout and during transition
156+
if (is_array($target)) {
157+
$uri= $target['uri'];
158+
$seed= $target['seed'];
159+
} else {
160+
$uri= $target;
161+
$seed= [];
162+
}
163+
153164
// Exchange the auth code for an access token
154-
$stored['token']= $this->backend->acquire([
165+
$params= [
155166
'grant_type' => 'authorization_code',
156167
'code' => $request->param('code'),
157168
'redirect_uri' => $callback,
158169
'state' => $server
159-
]);
170+
];
171+
$stored['token']= $this->backend->acquire($params, $seed);
160172
$session->register($this->namespace, $stored);
161173
$session->transmit($response);
162174

163175
// Redirect to self, using encoded fragment if present
164-
$this->finalize($response, $target.(isset($state[1]) ? '#'.urldecode($state[1]) : ''));
176+
$this->finalize($response, $uri.(isset($state[1]) ? '#'.urldecode($state[1]) : ''));
165177
return null;
166178
}
167179

src/test/php/web/auth/unittest/ByCertificateTest.class.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function jwt_headers_with($fingerprint) {
5454
#[Test, Values([3600, 86400])]
5555
public function jwt_payload_with($validity) {
5656
$time= time();
57-
$params= (new ByCertificate(self::CLIENT_ID, self::FINGERPRINT, $this->privateKey, $validity))->params(self::ENDPOINT, $time);
57+
$params= (new ByCertificate(self::CLIENT_ID, self::FINGERPRINT, $this->privateKey, $validity))->params(self::ENDPOINT, ['time' => $time]);
5858
$payload= json_decode(base64_decode(explode('.', $params['client_assertion'])[1]), true);
5959

6060
Assert::equals(
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php namespace web\auth\unittest;
2+
3+
use lang\IllegalArgumentException;
4+
use test\{Assert, Expect, Test, Values};
5+
use web\auth\oauth\ByPKCE;
6+
7+
class ByPKCETest {
8+
const CLIENT_ID= 'b2ba8814';
9+
const TEST_SEED= ['verifier' => 'test-challenge'];
10+
11+
/** @return iterable */
12+
private function challenges() {
13+
yield ['S256', 'Xuq1l4Pllrvf6AJ2BfBwnQFQKBK7dnKAbolZ3zvWFlw']; // base64(sha256(TEST_SEED[verifier]))
14+
yield ['plain', 'test-challenge'];
15+
}
16+
17+
#[Test, Values(ByPKCE::SUPPORTED)]
18+
public function can_create_with($method) {
19+
new ByPKCE(self::CLIENT_ID, $method);
20+
}
21+
22+
#[Test, Values(['S128', 'invalid']), Expect(IllegalArgumentException::class)]
23+
public function unsupported($method) {
24+
new ByPKCE(self::CLIENT_ID, $method);
25+
}
26+
27+
#[Test]
28+
public function seed_creates_verifier() {
29+
Assert::matches(
30+
'/^[a-zA-Z0-9._~-]{64}$/',
31+
(new ByPKCE(self::CLIENT_ID, 'S256'))->seed()['verifier']
32+
);
33+
}
34+
35+
#[Test, Values(from: 'challenges')]
36+
public function pass($method, $challenge) {
37+
Assert::equals(
38+
['code_challenge' => $challenge, 'code_challenge_method' => $method],
39+
(new ByPKCE(self::CLIENT_ID, $method))->pass(self::TEST_SEED)
40+
);
41+
}
42+
43+
#[Test]
44+
public function params() {
45+
Assert::equals(
46+
['client_id' => self::CLIENT_ID, 'code_verifier' => 'test-challenge'],
47+
(new ByPKCE(self::CLIENT_ID, 'S256'))->params('https://test/oauth/tokens', self::TEST_SEED)
48+
);
49+
}
50+
}

src/test/php/web/auth/unittest/OAuth2FlowTest.class.php

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public function redirects_to_auth($path) {
9191
$this->authenticate($fixture, $path, $session),
9292
$session
9393
);
94-
Assert::equals('http://localhost'.$path, current($session->value(self::SNS)['flow']));
94+
Assert::equals(['uri' => 'http://localhost'.$path, 'seed' => []], current($session->value(self::SNS)['flow']));
9595
}
9696

9797
#[Test, Values(from: 'paths')]
@@ -105,7 +105,7 @@ public function redirects_to_auth_with_relative_callback($path) {
105105
$this->authenticate($fixture, $path, $session),
106106
$session
107107
);
108-
Assert::equals('http://localhost'.$path, current($session->value(self::SNS)['flow']));
108+
Assert::equals(['uri' => 'http://localhost'.$path, 'seed' => []], current($session->value(self::SNS)['flow']));
109109
}
110110

111111
#[Test, Values(from: 'paths')]
@@ -119,7 +119,7 @@ public function redirects_to_auth_using_request($path) {
119119
$this->authenticate($fixture->target(new UseRequest()), $path, $session),
120120
$session
121121
);
122-
Assert::equals('http://localhost'.$path, current($session->value(self::SNS)['flow']));
122+
Assert::equals(['uri' => 'http://localhost'.$path, 'seed' => []], current($session->value(self::SNS)['flow']));
123123
}
124124

125125
#[Test, Values(from: 'paths')]
@@ -133,7 +133,7 @@ public function redirects_to_auth_using_url($path) {
133133
$this->authenticate($fixture->target(new UseURL(self::SERVICE)), $path, $session),
134134
$session
135135
);
136-
Assert::equals(self::SERVICE.$path, current($session->value(self::SNS)['flow']));
136+
Assert::equals(['uri' => self::SERVICE.$path, 'seed' => []], current($session->value(self::SNS)['flow']));
137137
}
138138

139139
#[Test, Values(from: 'fragments')]
@@ -147,7 +147,7 @@ public function redirects_to_sso_with_fragment($fragment) {
147147
$this->authenticate($fixture, '/#'.$fragment, $session),
148148
$session
149149
);
150-
Assert::equals('http://localhost/#'.$fragment, current($session->value(self::SNS)['flow']));
150+
Assert::equals(['uri' => 'http://localhost/#'.$fragment, 'seed' => []], current($session->value(self::SNS)['flow']));
151151
}
152152

153153
#[Test, Values([[['user']], [['user', 'openid']]])]
@@ -196,7 +196,7 @@ public function passes_client_id_and_secret() {
196196
]);
197197
$fixture= new OAuth2Flow(self::AUTH, $tokens, $credentials, self::CALLBACK);
198198
$session= (new ForTesting())->create();
199-
$session->register('oauth2::flow', ['flow' => [$state => self::SERVICE]]);
199+
$session->register('oauth2::flow', ['flow' => [$state => ['uri' => self::SERVICE, 'seed' => []]]]);
200200

201201
$this->authenticate($fixture, '/?code=SERVER_CODE&state='.$state, $session);
202202
Assert::equals('authorization_code', $passed['grant_type']);
@@ -214,7 +214,7 @@ public function passes_client_id_assertion_and_rs256_jwt() {
214214
]);
215215
$fixture= new OAuth2Flow(self::AUTH, $tokens, $credentials, self::CALLBACK);
216216
$session= (new ForTesting())->create();
217-
$session->register('oauth2::flow', ['flow' => [$state => self::SERVICE]]);
217+
$session->register('oauth2::flow', ['flow' => [$state => ['uri' => self::SERVICE, 'seed' => []]]]);
218218

219219
$this->authenticate($fixture, '/?code=SERVER_CODE&state='.$state, $session);
220220
Assert::equals('authorization_code', $passed['grant_type']);
@@ -233,7 +233,7 @@ public function gets_access_token_and_redirects_to_self() {
233233
]);
234234
$fixture= new OAuth2Flow(self::AUTH, $tokens, self::CONSUMER, self::CALLBACK);
235235
$session= (new ForTesting())->create();
236-
$session->register('oauth2::flow', ['flow' => [$state => self::SERVICE]]);
236+
$session->register('oauth2::flow', ['flow' => [$state => ['uri' => self::SERVICE, 'seed' => []]]]);
237237

238238
$res= $this->authenticate($fixture, '/?code=SERVER_CODE&state='.$state, $session);
239239
Assert::equals(self::SERVICE, $res->headers()['Location']);
@@ -266,7 +266,7 @@ public function gets_access_token_and_redirects_to_self_with_fragment($fragment)
266266
]);
267267
$fixture= new OAuth2Flow(self::AUTH, $tokens, self::CONSUMER, self::CALLBACK);
268268
$session= (new ForTesting())->create();
269-
$session->register('oauth2::flow', ['flow' => [$state => self::SERVICE]]);
269+
$session->register('oauth2::flow', ['flow' => [$state => ['uri' => self::SERVICE, 'seed' => []]]]);
270270

271271
$res= $this->authenticate($fixture, '/?code=SERVER_CODE&state='.$state.OAuth2Flow::FRAGMENT.urlencode($fragment), $session);
272272
Assert::equals(self::SERVICE.'#'.$fragment, $res->headers()['Location']);
@@ -277,7 +277,7 @@ public function gets_access_token_and_redirects_to_self_with_fragment($fragment)
277277
public function raises_exception_on_state_mismatch() {
278278
$fixture= new OAuth2Flow(self::AUTH, self::TOKENS, self::CONSUMER, self::CALLBACK);
279279
$session= (new ForTesting())->create();
280-
$session->register('oauth2::flow', ['flow' => ['CLIENTSTATE' => self::SERVICE]]);
280+
$session->register('oauth2::flow', ['flow' => ['CLIENTSTATE' => ['uri' => self::SERVICE, 'seed' => []]]]);
281281

282282
$this->authenticate($fixture, '/?state=SERVERSTATE&code=SERVER_CODE', $session);
283283
}
@@ -414,7 +414,10 @@ public function parallel_requests_stored() {
414414
$this->authenticate($fixture, '/favicon.ico', $session);
415415

416416
Assert::equals(
417-
['http://localhost/new', 'http://localhost/favicon.ico'],
417+
[
418+
['uri' => 'http://localhost/new', 'seed' => []],
419+
['uri' => 'http://localhost/favicon.ico', 'seed' => []],
420+
],
418421
array_values($session->value(self::SNS)['flow'])
419422
);
420423
}

0 commit comments

Comments
 (0)