Skip to content

Commit

Permalink
Merge pull request #30 from xp-forge/feature/session-namespaces
Browse files Browse the repository at this point in the history
Make it possible to change the session namespace
  • Loading branch information
thekid authored Jul 17, 2024
2 parents 3a2b22a + f6ff41c commit 0a2e2c0
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 48 deletions.
13 changes: 6 additions & 7 deletions src/main/php/web/auth/oauth/OAuth1Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@

/** @test web.auth.unittest.OAuth1FlowTest */
class OAuth1Flow extends OAuthFlow {
const SESSION_KEY= 'oauth1::flow';

private $service, $signature;

/**
Expand All @@ -19,6 +17,7 @@ class OAuth1Flow extends OAuthFlow {
* @param string|util.URI $callback
*/
public function __construct($service, $consumer, $callback= null) {
$this->namespace= 'oauth1::flow';
$this->service= rtrim($service, '/');

// BC: Support web.auth.oauth.Token instances
Expand Down Expand Up @@ -77,11 +76,11 @@ protected function request($path, $token= null, $params= []) {
* @throws lang.IllegalStateException
*/
public function authenticate($request, $response, $session) {
$state= $session->value(self::SESSION_KEY);
$state= $session->value($this->namespace);

// We have an access token, reset state and return an authenticated session
if (isset($state['access'])) {
$session->remove(self::SESSION_KEY);
$session->remove($this->namespace);
return new BySignedRequests($this->signature->with(new BySecret($state['oauth_token'], $state['oauth_token_secret'])));
}

Expand All @@ -93,7 +92,7 @@ public function authenticate($request, $response, $session) {
$state['target'].= '#'.$fragment;
}

$session->register(self::SESSION_KEY, $state);
$session->register($this->namespace, $state);
$session->transmit($response);
$response->send('document.location.replace(target)', 'text/javascript');
return null;
Expand All @@ -106,7 +105,7 @@ public function authenticate($request, $response, $session) {
$server= $request->param('oauth_token');
if (null === $state || null === $server) {
$token= $this->request('/request_token', null, ['oauth_callback' => $callback]);
$session->register(self::SESSION_KEY, $token + ['target' => (string)$uri]);
$session->register($this->namespace, $token + ['target' => (string)$uri]);
$session->transmit($response);

// Redirect the user to the authorization page
Expand Down Expand Up @@ -139,7 +138,7 @@ public function authenticate($request, $response, $session) {

// Back from authentication redirect, upgrade request token to access token
$access= $this->request('/access_token', $state['oauth_token'], ['oauth_verifier' => $request->param('oauth_verifier')]);
$session->register(self::SESSION_KEY, $access + ['access' => true]);
$session->register($this->namespace, $access + ['access' => true]);
$session->transmit($response);

// Redirect to self
Expand Down
11 changes: 5 additions & 6 deletions src/main/php/web/auth/oauth/OAuth2Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@

/** @test web.auth.unittest.OAuth2FlowTest */
class OAuth2Flow extends OAuthFlow {
const SESSION_KEY= 'oauth2::flow';

private $auth, $backend, $scopes, $rand;

/**
Expand All @@ -22,6 +20,7 @@ class OAuth2Flow extends OAuthFlow {
* @param string[] $scopes
*/
public function __construct($auth, $tokens, $consumer, $callback= null, $scopes= ['user']) {
$this->namespace= 'oauth2::flow';
$this->auth= $auth instanceof URI ? $auth : new URI($auth);
$this->backend= $tokens instanceof OAuth2Endpoint
? $tokens->using($consumer)
Expand Down Expand Up @@ -85,13 +84,13 @@ public function refresh(array $claims) {
* @throws lang.IllegalStateException
*/
public function authenticate($request, $response, $session) {
$stored= $session->value(self::SESSION_KEY);
$stored= $session->value($this->namespace);

// We have an access token, reset state and return an authenticated session
// See https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
// and https://tools.ietf.org/html/rfc6749#section-5.1
if (isset($stored['access_token'])) {
$session->remove(self::SESSION_KEY);
$session->remove($this->namespace);
return new ByAccessToken(
$stored['access_token'],
$stored['token_type'] ?? 'Bearer',
Expand All @@ -114,7 +113,7 @@ public function authenticate($request, $response, $session) {
$state= $stored['state'];
} else {
$state= bin2hex($this->rand->bytes(16));
$session->register(self::SESSION_KEY, ['state' => $state, 'target' => (string)$uri]);
$session->register($this->namespace, ['state' => $state, 'target' => (string)$uri]);
$session->transmit($response);
}

Expand Down Expand Up @@ -156,7 +155,7 @@ public function authenticate($request, $response, $session) {
'redirect_uri' => $callback,
'state' => $stored['state']
]);
$session->register(self::SESSION_KEY, $token);
$session->register($this->namespace, $token);
$session->transmit($response);

// Redirect to self, using encoded fragment if present
Expand Down
16 changes: 14 additions & 2 deletions src/main/php/web/auth/oauth/OAuthFlow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use web\auth\{Flow, UserInfo, AuthenticationError};

abstract class OAuthFlow extends Flow {
protected $callback;
protected $callback, $namespace;

/** @return ?util.URI */
public function callback() { return $this->callback; }
Expand All @@ -15,9 +15,21 @@ public function calling($callback): self {
return $this;
}

/**
* Sets session namespace for this flow. Used to prevent conflicts
* in session state with multiple OAuth flows in place.
*
* @param string $namespace
* @return self
*/
public function namespaced($namespace) {
$this->namespace= $namespace;
return $this;
}

/**
* Returns user info which fetched from the given endpoint using the
* authorized OAuth2 client
* authorized OAuth client
*
* @param string|util.URI $endpoint
* @return web.auth.UserInfo
Expand Down
37 changes: 25 additions & 12 deletions src/test/php/web/auth/unittest/OAuth1FlowTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class OAuth1FlowTest extends FlowTest {
use Clients;

const SNS = 'oauth1::flow';
const AUTH = 'https://example.com/oauth';
const ID = 'bf396750';
const SECRET = '5ebe2294ecd0e0f08eab7690d2a6ee69';
Expand Down Expand Up @@ -47,7 +48,7 @@ public function fetches_request_token_then_redirects_to_auth($path) {
sprintf('%s/authenticate?oauth_token=T&oauth_callback=%s', self::AUTH, urlencode(self::CALLBACK)),
$this->redirectTo($this->authenticate($fixture, $path, $session))
);
Assert::equals('http://localhost'.$path, $session->value(OAuth1Flow::SESSION_KEY)['target']);
Assert::equals('http://localhost'.$path, $session->value(self::SNS)['target']);
}

#[Test, Values(from: 'fragments')]
Expand All @@ -62,7 +63,7 @@ public function fetches_request_token_then_redirects_to_auth_with_fragment_in_sp
sprintf('%s/authenticate?oauth_token=T&oauth_callback=%s', self::AUTH, urlencode(self::CALLBACK)),
$this->redirectTo($this->authenticate($fixture, '/#'.$fragment, $session))
);
Assert::equals('http://localhost/#'.$fragment, $session->value(OAuth1Flow::SESSION_KEY)['target']);
Assert::equals('http://localhost/#'.$fragment, $session->value(self::SNS)['target']);
}

#[Test]
Expand All @@ -72,18 +73,18 @@ public function exchanges_request_token_for_access_token() {
'request' => function($path, $token= null, $params= []) use($access) { return $access; }
]);
$session= (new ForTesting())->create();
$session->register(OAuth1Flow::SESSION_KEY, ['oauth_token' => 'REQUEST-TOKEN', 'target' => self::SERVICE]);
$session->register(self::SNS, ['oauth_token' => 'REQUEST-TOKEN', 'target' => self::SERVICE]);

$res= $this->authenticate($fixture, '/?oauth_token=REQUEST-TOKEN&oauth_verifier=ABC', $session);
Assert::equals(self::SERVICE, $res->headers()['Location']);
Assert::equals($access, $session->value(OAuth1Flow::SESSION_KEY));
Assert::equals($access, $session->value(self::SNS));
}

#[Test, Expect(IllegalStateException::class)]
public function raises_exception_on_state_mismatch() {
$fixture= new OAuth1Flow(self::AUTH, [self::ID, self::SECRET], self::CALLBACK);
$session= (new ForTesting())->create();
$session->register(OAuth1Flow::SESSION_KEY, ['oauth_token' => 'REQUEST-TOKEN', 'target' => self::SERVICE]);
$session->register(self::SNS, ['oauth_token' => 'REQUEST-TOKEN', 'target' => self::SERVICE]);

$this->authenticate($fixture, '/?oauth_token=MISMATCHED-TOKEN&oauth_verifier=ABC', $session);
}
Expand All @@ -96,7 +97,7 @@ public function returns_client() {
$req= new Request(new TestInput('GET', '/'));
$res= new Response(new TestOutput());
$session= (new ForTesting())->create();
$session->register(OAuth1Flow::SESSION_KEY, $access);
$session->register(self::SNS, $access);

Assert::instance(Client::class, $fixture->authenticate($req, $res, $session));
}
Expand All @@ -109,10 +110,10 @@ public function resets_state_after_returning_client() {
$req= new Request(new TestInput('GET', '/'));
$res= new Response(new TestOutput());
$session= (new ForTesting())->create();
$session->register(OAuth1Flow::SESSION_KEY, $access);
$session->register(self::SNS, $access);
$fixture->authenticate($req, $res, $session);

Assert::null($session->value(OAuth1Flow::SESSION_KEY));
Assert::null($session->value(self::SNS));
}

#[Test, Values(from: 'fragments')]
Expand All @@ -122,11 +123,11 @@ public function appends_fragment($fragment) {
$req= new Request(new TestInput('GET', '/?'.OAuth1Flow::FRAGMENT.'='.urlencode($fragment)));
$res= new Response(new TestOutput());
$session= (new ForTesting())->create();
$session->register(OAuth1Flow::SESSION_KEY, ['target' => 'http://localhost/']);
$session->register(self::SNS, ['target' => 'http://localhost/']);

$fixture->authenticate($req, $res, $session);

Assert::equals('http://localhost/#'.$fragment, $session->value(OAuth1Flow::SESSION_KEY)['target']);
Assert::equals('http://localhost/#'.$fragment, $session->value(self::SNS)['target']);
}

#[Test, Values(from: 'fragments')]
Expand All @@ -136,11 +137,11 @@ public function replaces_fragment($fragment) {
$req= new Request(new TestInput('GET', '/?'.OAuth1Flow::FRAGMENT.'='.urlencode($fragment)));
$res= new Response(new TestOutput());
$session= (new ForTesting())->create();
$session->register(OAuth1Flow::SESSION_KEY, ['target' => 'http://localhost/#original']);
$session->register(self::SNS, ['target' => 'http://localhost/#original']);

$fixture->authenticate($req, $res, $session);

Assert::equals('http://localhost/#'.$fragment, $session->value(OAuth1Flow::SESSION_KEY)['target']);
Assert::equals('http://localhost/#'.$fragment, $session->value(self::SNS)['target']);
}

/** @deprecated */
Expand Down Expand Up @@ -180,4 +181,16 @@ public function fetch_user_info() {
$fixture($this->responding(200, ['Content-Type' => 'application/json'], '{"id":"root"}'))
);
}

#[Test, Values(['oauth::flow', 'flow'])]
public function session_namespace($namespace) {
$request= ['oauth_token' => 'T'];
$fixture= newinstance(OAuth1Flow::class, [self::AUTH, [self::ID, self::SECRET], self::CALLBACK], [
'request' => function($path, $token= null, $params= []) use($request) { return $request; }
]);
$session= (new ForTesting())->create();
$this->authenticate($fixture->namespaced($namespace), '/target', $session);

Assert::equals('http://localhost/target', $session->value($namespace)['target']);
}
}
Loading

0 comments on commit 0a2e2c0

Please sign in to comment.