Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/php/web/auth/oauth/ByCertificate.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ public function __construct($clientId, $fingerprint, $privateKey, $validity= 360
}

/** Returns parameters to be used in authentication process */
public function params(string $endpoint, $time= null): array {
$time ?? $time= time();
public function params(string $endpoint, array $seed= []): array {
$time= $seed['time'] ?? time();
$jwt= new JWT(['alg' => 'RS256', 'typ' => 'JWT', 'x5t' => JWT::encode(hex2bin($this->fingerprint))], [
'aud' => $endpoint,
'exp' => $time + $this->validity,
Expand Down
61 changes: 61 additions & 0 deletions src/main/php/web/auth/oauth/ByPKCE.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php namespace web\auth\oauth;

use lang\IllegalArgumentException;

/** @test web.auth.unittest.ByPKCETest */
class ByPKCE extends Credentials {
private $challenge, $method;

/**
* Creates credentials with a client ID and method.
* Support the `S256` and `plain` methods.
Comment thread
thekid marked this conversation as resolved.
*
* @param string $clientId
* @param string $method
* @throws lang.IllegalArgumentException
*/
public function __construct($clientId, $method) {
parent::__construct($clientId);

if ('S256' === $method) {
$this->challenge= fn($verifier) => JWT::encode(hash('sha256', $verifier, true));
$this->method= 'S256';
} else if ('plain' === $method) {
$this->challenge= fn($verifier) => $verifier;
$this->method= 'plain';
} else {
throw new IllegalArgumentException('Unsupported method '.$method);
}
}

/** @return string */
public function method() { return $this->method; }

/** Returns authorization seed */
public function seed(): array {
static $UNRESERVED= 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';

$random= random_bytes(64);
$verifier= '';
for ($i= 0; $i < 64; $i++) {
$verifier.= $UNRESERVED[ord($random[$i]) % 66];
}
return ['verifier' => $verifier];
}

/** Returns parameters to be passed on to authorization */
public function pass(array $seed): array {
return [
'code_challenge' => ($this->challenge)($seed['verifier']),
'code_challenge_method' => $this->method,
];
}

/** Returns parameters to be used in authentication process */
public function params(string $endpoint, array $seed= []): array {
return [
'client_id' => $this->key,
'code_verifier' => $seed['verifier'],
];
}
}
2 changes: 1 addition & 1 deletion src/main/php/web/auth/oauth/BySecret.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function __construct($clientId, $secret) {
public function secret() { return $this->secret; }

/** Returns parameters to be used in authentication process */
public function params(string $endpoint, $time= null): array {
public function params(string $endpoint, array $seed= []): array {
return [
'client_id' => $this->key,
'client_secret' => $this->secret->reveal(),
Expand Down
10 changes: 8 additions & 2 deletions src/main/php/web/auth/oauth/Credentials.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ abstract class Credentials {

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

/** Returns authorization seed */
public function seed(): array { return []; }

/** Returns parameters to be passed on to authorization */
public function pass(array $seed): array { return []; }

/** Returns parameters to be used in authentication process */
public abstract function params(string $endpoint, $time= null): array;
public abstract function params(string $endpoint, array $seed= []): array;
}
21 changes: 17 additions & 4 deletions src/main/php/web/auth/oauth/OAuth2Endpoint.class.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php namespace web\auth\oauth;

use peer\http\HttpConnection;
use lang\IllegalStateException;
use io\streams\Streams;
use lang\IllegalStateException;
use peer\http\HttpConnection;

class OAuth2Endpoint {
private $conn, $credentials;
Expand Down Expand Up @@ -77,13 +77,26 @@ protected function request($payload) {
}
}

/** @return [:string] */
public function seed() { return $this->credentials->seed(); }

/**
* Returns authorization parameters
*
* @param [:string] $grant
* @param [:string] $seed
* @return [:string]
*/
public function pass($auth, $seed= []) { return $this->credentials->pass($seed) + $auth; }

/**
* Acquires a grant
*
* @param [:string] $grant
* @param [:string] $seed
* @return [:string]
*/
public function acquire($grant) {
return $this->request($this->credentials->params($this->conn->getUrl()->getCanonicalURL()) + $grant);
public function acquire($grant, $seed= []) {
return $this->request($this->credentials->params($this->conn->getUrl()->getCanonicalURL(), $seed) + $grant);
}
}
24 changes: 18 additions & 6 deletions src/main/php/web/auth/oauth/OAuth2Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ public function authenticate($request, $response, $session) {
$server= $request->param('state');
if (null === $server || null === $stored) {
$state= bin2hex($this->rand->bytes(16));
$seed= $this->backend->seed();

$stored??= ['flow' => []];
$stored['flow'][$state]= (string)$uri;
$stored['flow'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
$session->register($this->namespace, $stored);
$session->transmit($response);

Expand All @@ -121,9 +123,9 @@ public function authenticate($request, $response, $session) {
'client_id' => $this->backend->clientId(),
'scope' => implode(' ', $this->scopes),
'redirect_uri' => $callback,
'state' => $state
'state' => $state,
];
$target= $this->auth->using()->params($params)->create();
$target= $this->auth->using()->params($this->backend->pass($params, $seed))->create();

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

// Target is an array for old session layout and during transition
if (is_array($target)) {
$uri= $target['uri'];
$seed= $target['seed'];
} else {
$uri= $target;
$seed= [];
}

// Exchange the auth code for an access token
$stored['token']= $this->backend->acquire([
$params= [
'grant_type' => 'authorization_code',
'code' => $request->param('code'),
'redirect_uri' => $callback,
'state' => $server
]);
];
$stored['token']= $this->backend->acquire($params, $seed);
$session->register($this->namespace, $stored);
$session->transmit($response);

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

Expand Down
2 changes: 1 addition & 1 deletion src/test/php/web/auth/unittest/ByCertificateTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function jwt_headers_with($fingerprint) {
#[Test, Values([3600, 86400])]
public function jwt_payload_with($validity) {
$time= time();
$params= (new ByCertificate(self::CLIENT_ID, self::FINGERPRINT, $this->privateKey, $validity))->params(self::ENDPOINT, $time);
$params= (new ByCertificate(self::CLIENT_ID, self::FINGERPRINT, $this->privateKey, $validity))->params(self::ENDPOINT, ['time' => $time]);
$payload= json_decode(base64_decode(explode('.', $params['client_assertion'])[1]), true);

Assert::equals(
Expand Down
50 changes: 50 additions & 0 deletions src/test/php/web/auth/unittest/ByPKCETest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php namespace web\auth\unittest;

use lang\IllegalArgumentException;
use test\{Assert, Expect, Test, Values};
use web\auth\oauth\ByPKCE;

class ByPKCETest {
const CLIENT_ID= 'b2ba8814';
const TEST_SEED= ['verifier' => 'test-challenge'];

/** @return iterable */
private function challenges() {
yield ['S256', 'Xuq1l4Pllrvf6AJ2BfBwnQFQKBK7dnKAbolZ3zvWFlw']; // base64(sha256(TEST_SEED[verifier]))
yield ['plain', 'test-challenge'];
}

#[Test, Values(['S256', 'plain'])]
public function can_create_with($method) {
new ByPKCE(self::CLIENT_ID, $method);
}

#[Test, Values(['S128', 'invalid']), Expect(IllegalArgumentException::class)]
public function unsupported($method) {
new ByPKCE(self::CLIENT_ID, $method);
}

#[Test]
public function seed_creates_verifier() {
Assert::matches(
'/^[a-zA-Z0-9._~-]{64}$/',
(new ByPKCE(self::CLIENT_ID, 'S256'))->seed()['verifier']
);
}

#[Test, Values(from: 'challenges')]
public function pass($method, $challenge) {
Assert::equals(
['code_challenge' => $challenge, 'code_challenge_method' => $method],
(new ByPKCE(self::CLIENT_ID, $method))->pass(self::TEST_SEED)
);
}

#[Test]
public function params() {
Assert::equals(
['client_id' => self::CLIENT_ID, 'code_verifier' => 'test-challenge'],
(new ByPKCE(self::CLIENT_ID, 'S256'))->params('https://test/oauth/tokens', self::TEST_SEED)
);
}
}
25 changes: 14 additions & 11 deletions src/test/php/web/auth/unittest/OAuth2FlowTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public function redirects_to_auth($path) {
$this->authenticate($fixture, $path, $session),
$session
);
Assert::equals('http://localhost'.$path, current($session->value(self::SNS)['flow']));
Assert::equals(['uri' => 'http://localhost'.$path, 'seed' => []], current($session->value(self::SNS)['flow']));
}

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

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

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

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

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

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

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

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

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

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

Assert::equals(
['http://localhost/new', 'http://localhost/favicon.ico'],
[
['uri' => 'http://localhost/new', 'seed' => []],
['uri' => 'http://localhost/favicon.ico', 'seed' => []],
],
array_values($session->value(self::SNS)['flow'])
);
}
Expand Down
Loading