Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add web.auth.UserInfo to map the returned user from a flow #27

Merged
merged 12 commits into from
Jan 1, 2024
8 changes: 8 additions & 0 deletions src/main/php/web/auth/AuthenticationError.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php namespace web\auth;

use lang\XPException;

/** Indicates an authentication error occurred */
class AuthenticationError extends XPException {

}
9 changes: 9 additions & 0 deletions src/main/php/web/auth/Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ public function url($default= false): URL {
return $this->url ?? ($default ? $this->url= new UseRequest() : null);
}

/**
* Returns a user info instance
*
* @return web.auth.UserInfo
*/
public function userInfo(): UserInfo {
return new UserInfo(function($result) { return $result; });
}

/**
* Replaces fragment by special parameter. This is really only for test
* code, real request URIs will never have a fragment as these are a
Expand Down
64 changes: 64 additions & 0 deletions src/main/php/web/auth/UserInfo.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php namespace web\auth;

use Iterator, Throwable;
use web\auth\AuthenticationError;

/**
* Retrieves details about the authenticated user from a given endpoint.
*
* @test web.auth.unittest.UserInfoTest
*/
class UserInfo {
private $supplier;
private $map= [];

/** @param function(var): var $supplier */
public function __construct(callable $supplier) { $this->supplier= $supplier; }

/**
* Maps the user info using the given the function.
*
* @param (function(var): var)|(function(var, var): var) $function
* @return self
*/
public function map(callable $function) {
$this->map[]= $function;
return $this;
}

/**
* Peeks into the given results. Useful for debugging.
*
* @param (function(var): void)|(function(var, var): void) $function
* @return self
*/
public function peek(callable $function) {
$this->map[]= function($value, $result) use($function) {
$function($value, $result);
return $value;
};
return $this;
}

/**
* Fetches the user info and maps the returned value.
*
* @param var $result Authentication flow result
* @return var The user object
* @throws web.auth.AuthenticationError
*/
public function __invoke($result) {
try {
$value= ($this->supplier)($result);
foreach ($this->map as $function) {
$result= $function($value, $result);
$value= $result instanceof Iterator ? iterator_to_array($result) : $result;
}
return $value;
} catch (AuthenticationError $e) {
throw $e;
} catch (Throwable $t) {
throw new AuthenticationError('Invoking mappers: '.$t->getMessage(), $t);
}
}
}
19 changes: 18 additions & 1 deletion src/main/php/web/auth/oauth/OAuth2Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use lang\IllegalStateException;
use peer\http\HttpConnection;
use util\{Random, Secret, URI};
use web\auth\Flow;
use web\auth\{Flow, UserInfo, AuthenticationError};
use web\session\Sessions;

class OAuth2Flow extends Flow {
Expand Down Expand Up @@ -47,6 +47,23 @@ public function callback() { return $this->callback; }
/** @return string[] */
public function scopes() { return $this->scopes; }

/**
* Returns user info which fetched from the given endpoint using the
* authorized OAuth2 client
*
* @param string|util.URI $endpoint
* @return web.auth.UserInfo
*/
public function fetchUser($endpoint= null): UserInfo {
return new UserInfo(function(Client $client) use($endpoint) {
$response= $client->fetch((string)$endpoint);
if ($response->status() >= 400) {
throw new AuthenticationError('Unexpected status '.$response->status().' from '.$endpoint);
}
return $response->value();
});
}

/**
* Refreshes access token given a refresh token if necessary.
*
Expand Down
41 changes: 40 additions & 1 deletion src/test/php/web/auth/unittest/OAuth2FlowTest.class.php
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<?php namespace web\auth\unittest;

use io\streams\MemoryInputStream;
use lang\IllegalStateException;
use peer\http\HttpResponse;
use test\verify\Runtime;
use test\{Assert, Expect, Test, TestCase, Values};
use util\URI;
use web\auth\oauth\{Client, BySecret, ByCertificate, Token, OAuth2Flow, OAuth2Endpoint};
use web\auth\oauth\{Client, BySecret, ByCertificate, Token, OAuth2Flow, OAuth2Endpoint, Response as OAuthResponse};
use web\auth\{UseCallback, UseRequest, UseURL};
use web\io\{TestInput, TestOutput};
use web\session\ForTesting;
Expand Down Expand Up @@ -41,6 +43,21 @@ private function assertLoginWith($service, $scope, $res, $session) {
Assert::equals($url, $this->redirectTo($res));
}

/* Returns a client whose `fetch()` operation returns the given response */
public function responding(int $status, array $headers, string $payload): Client {
return newinstance(Client::class, [], [
'authorize' => function($request) { return $request; },
'token' => function() { return 'TOKEN'; },
'fetch' => function($url, $options= []) use($status, $headers, $payload) {
$message= "HTTP/1.1 {$status} ...\r\n";
foreach ($headers + ['Content-Length' => strlen($payload)] as $name => $value) {
$message.= "{$name}: {$value}\r\n";
}
return new OAuthResponse(new HttpResponse(new MemoryInputStream($message."\r\n".$payload)));
}
]);
}

#[Test]
public function can_create() {
new OAuth2Flow(self::AUTH, self::TOKENS, self::CONSUMER, self::CALLBACK);
Expand Down Expand Up @@ -329,4 +346,26 @@ public function deprecated_usage_with_scopes_in_place_of_callback_uri($path) {
$session
);
}

#[Test]
public function use_returned_client() {
$flow= new OAuth2Flow(self::AUTH, self::TOKENS, self::CONSUMER, self::CALLBACK);
$fixture= $flow->userInfo();

Assert::instance(
Client::class,
$fixture($this->responding(200, ['Content-Type' => 'application/json'], '{"id":"root"}'))
);
}

#[Test]
public function fetch_user_info() {
$flow= new OAuth2Flow(self::AUTH, self::TOKENS, self::CONSUMER, self::CALLBACK);
$fixture= $flow->fetchUser('http://example.com/graph/v1.0/me');

Assert::equals(
['id' => 'root'],
$fixture($this->responding(200, ['Content-Type' => 'application/json'], '{"id":"root"}'))
);
}
}
113 changes: 113 additions & 0 deletions src/test/php/web/auth/unittest/UserInfoTest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php namespace web\auth\unittest;

use lang\IllegalStateException;
use test\{Assert, Before, Expect, Test, Values};
use web\auth\{UserInfo, AuthenticationError};

class UserInfoTest {
const USER= ['id' => 6100];

private $returned;


/** @return iterable */
private function mappers() {
$instance= new class() {
public function first($user) { return ['first' => $user]; }
public function second($user) { return ['second' => $user, 'aggregated' => true]; }
};
yield [
[$instance, 'first'],
[$instance, 'second']
];
yield [
function($user) { return ['first' => $user]; },
function($user) { return ['second' => $user, 'aggregated' => true]; },
];
yield [
function($user) { yield 'first' => $user; },
function($user) { yield 'second' => $user; yield 'aggregated' => true; },
];
yield [
new class() { public function __invoke($user) { return ['first' => $user]; }},
new class() { public function __invoke($user) { return ['second' => $user, 'aggregated' => true]; }},
];
}

#[Before]
public function returned() {
$this->returned= function($source) { return $source; };
}

#[Test]
public function can_create_with_supplier() {
new UserInfo($this->returned);
}

#[Test]
public function fetch() {
$fixture= new UserInfo($this->returned);
Assert::equals(['id' => 'root'], $fixture(['id' => 'root']));
}

#[Test, Expect(AuthenticationError::class)]
public function fetch_raises_exception_when_endpoint_fails() {
$fixture= new UserInfo(function($source) {
throw new AuthenticationError('Internal Server Error');
});
$fixture(self::USER);
}

#[Test, Values(from: 'mappers')]
public function map_functions_executed($first, $second) {
$fixture= (new UserInfo($this->returned))->map($first)->map($second);
Assert::equals(
['second' => ['first' => self::USER], 'aggregated' => true],
$fixture(self::USER)
);
}

#[Test]
public function map_functions_have_access_to_result() {
$fixture= (new UserInfo($this->returned))->map(function($user, $result) {
return ['user' => $result->fetch(), 'token' => $result->token()];
});
Assert::equals(
['user' => self::USER, 'token' => 'TOKEN'],
$fixture(new class(self::USER) {
private $user;
public function __construct($user) { $this->user= $user; }
public function fetch() { return $this->user; }
public function token() { return 'TOKEN'; }
})
);
}

#[Test, Expect(AuthenticationError::class)]
public function map_wraps_invocation_exceptions() {
$fixture= (new UserInfo($this->returned))->map(function($user, $result) {
throw new IllegalStateException('Test');
});
$fixture(self::USER);
}

#[Test, Expect(AuthenticationError::class)]
public function map_wraps_supplier_exceptions() {
$fixture= new UserInfo(function($result) {
throw new IllegalStateException('Test');
});
$fixture(self::USER);
}

#[Test]
public function peek_function_executed() {
$invoked= [];
$fixture= (new UserInfo($this->returned))->peek(function($user, $result) use(&$invoked) {
$invoked[]= [$user, $result];
});
$user= $fixture(self::USER);

Assert::equals(self::USER, $user);
Assert::equals([[self::USER, self::USER]], $invoked);
}
}
Loading