diff --git a/src/main/php/web/auth/AuthenticationError.class.php b/src/main/php/web/auth/AuthenticationError.class.php new file mode 100755 index 0000000..309ea92 --- /dev/null +++ b/src/main/php/web/auth/AuthenticationError.class.php @@ -0,0 +1,8 @@ +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 diff --git a/src/main/php/web/auth/UserInfo.class.php b/src/main/php/web/auth/UserInfo.class.php new file mode 100755 index 0000000..897a0b7 --- /dev/null +++ b/src/main/php/web/auth/UserInfo.class.php @@ -0,0 +1,64 @@ +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); + } + } +} \ No newline at end of file diff --git a/src/main/php/web/auth/oauth/OAuth2Flow.class.php b/src/main/php/web/auth/oauth/OAuth2Flow.class.php index 4ee95c0..6eafa14 100755 --- a/src/main/php/web/auth/oauth/OAuth2Flow.class.php +++ b/src/main/php/web/auth/oauth/OAuth2Flow.class.php @@ -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 { @@ -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. * diff --git a/src/test/php/web/auth/unittest/OAuth2FlowTest.class.php b/src/test/php/web/auth/unittest/OAuth2FlowTest.class.php index b2dcc66..b1a5653 100755 --- a/src/test/php/web/auth/unittest/OAuth2FlowTest.class.php +++ b/src/test/php/web/auth/unittest/OAuth2FlowTest.class.php @@ -1,10 +1,12 @@ 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); @@ -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"}')) + ); + } } \ No newline at end of file diff --git a/src/test/php/web/auth/unittest/UserInfoTest.class.php b/src/test/php/web/auth/unittest/UserInfoTest.class.php new file mode 100755 index 0000000..bd3c30b --- /dev/null +++ b/src/test/php/web/auth/unittest/UserInfoTest.class.php @@ -0,0 +1,113 @@ + 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); + } +} \ No newline at end of file