Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
62 changes: 62 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,62 @@
<?php namespace web\auth\oauth;

use lang\IllegalArgumentException;

/** @test web.auth.unittest.ByPKCETest */
class ByPKCE extends Credentials {
const SUPPORTED= ['S256', 'plain'];

private $challenge, $method;

/**
* Creates credentials with a client ID and method.
* Support the `S256` and `plain` methods.
*
* @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));
} else if ('plain' === $method) {
$this->challenge= fn($verifier) => $verifier;
} else {
throw new IllegalArgumentException('Unsupported method '.$method.', expected one of ['.implode(', ', self::SUPPORTED).']');
}
$this->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(ByPKCE::SUPPORTED)]
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