Skip to content

Commit

Permalink
Merge pull request #27 from xp-forge/feature/userinfo
Browse files Browse the repository at this point in the history
Add web.auth.UserInfo to map the returned user from a flow
  • Loading branch information
thekid committed Jan 1, 2024
2 parents 2460af8 + 287ba53 commit 3b456a8
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 2 deletions.
8 changes: 8 additions & 0 deletions src/main/php/web/auth/AuthenticationError.class.php
@@ -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
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
@@ -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
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
@@ -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
@@ -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);
}
}

0 comments on commit 3b456a8

Please sign in to comment.