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
1 change: 1 addition & 0 deletions src/main/php/web/auth/Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

/** @test web.auth.unittest.FlowClassTest */
abstract class Flow {
const STATE= '%[^_]_%s';
const FRAGMENT= '_';

private $url= null;
Expand Down
16 changes: 16 additions & 0 deletions src/main/php/web/auth/oauth/ByAccessToken.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ public function __construct($token, $type= 'Bearer', $scope= null, $expires= nul
$this->id= null === $id ? null : ($id instanceof Secret ? $id : new Secret($id));
}

/**
* Creates an instance resulting from access token response
*
* @see https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
*/
public static function from(array $result): self {
return new self(
$result['access_token'],
$result['token_type'] ?? 'Bearer',
$result['scope'] ?? null,
$result['expires_in'] ?? null,
$result['refresh_token'] ?? null,
$result['id_token'] ?? null
);
}

/** @return util.Secret */
public function token() { return $this->token; }

Expand Down
93 changes: 39 additions & 54 deletions src/main/php/web/auth/oauth/OAuth1Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ protected function request($path, $token= null, $params= []) {
* @throws lang.IllegalStateException
*/
public function authenticate($request, $response, $session) {
$stored= $session->value($this->namespace);
$stored= $session->value($this->namespace) ?? ['flows' => []];

// We have an access token, reset state and return an authenticated session
// We have an access token, remove and return an authenticated session. The
// authentication implementation registers the user and transmits the session.
if ($token= $stored['token'] ?? null) {
unset($stored['token']);
$session->register($this->namespace, $stored);
Expand All @@ -89,85 +90,69 @@ public function authenticate($request, $response, $session) {
)));
}

$server= $request->param('oauth_token');
// Enter authentication flow, resolving callback URI against the curren request.
$uri= $this->url(true)->resolve($request);
$callback= $this->callback ? $uri->resolve($this->callback) : $this->service($uri);

// Start authenticaton flow by obtaining request token and store for later use
if (null === $server || null === $stored) {
$token= $this->request('/request_token', null, ['oauth_callback' => $callback])['oauth_token'];
$stored??= ['flow' => []];
$stored['flow'][$token]= (string)$uri;
// Check whether we are continuing an existing authentication flow based on the
// state given by the server and our session; or if we need to start a new one.
if (null === ($state= $request->param('oauth_token'))) {
$flow= null;
} else {
$flow= $this->flow($state, $stored);
}

if (null === $flow) {
$state= $this->request('/request_token', null, ['oauth_callback' => $callback])['oauth_token'];

$stored['flows'][$state]= ['uri' => (string)$uri, 'seed' => []];
$session->register($this->namespace, $stored);
$session->transmit($response);

// Redirect the user to the authorization page
$target= sprintf(
'%s/authenticate?oauth_token=%s&oauth_callback=%s',
$this->service,
urlencode($token),
urlencode($callback)
);
$token= urlencode($state);
$target= sprintf('%s/authenticate?oauth_token=%s&oauth_callback=%s', $this->service, $token, urlencode($callback));

// If a URL fragment is present, call ourselves to capture it inside the
// session; otherwise redirect the OAuth authentication service directly.
$this->redirect($response, $target, sprintf('
var target = "%1$s";
var hash = document.location.hash.substring(1);

$separator= self::FRAGMENT;
return $this->redirect($response, $target, <<<JS
var hash = document.location.hash;
if (hash) {
var s = document.createElement("script");
s.src = "%2$s?oauth_token=%4$s&%3$s=" + encodeURIComponent(hash) + "&" + Math.random();
var target = '{$target}';
var s = document.createElement('script');
s.src = '{$uri}?oauth_token={$token}&{$separator}=' + encodeURIComponent(hash.substring(1)) + '&' + Math.random();
document.body.appendChild(s);
} else {
document.location.replace(target);
}',
$target,
$uri,
self::FRAGMENT,
urlencode($token)
));
return null;
}
document.location.replace('{$target}');
}
JS
);
} else if ($fragment= $request->param(self::FRAGMENT)) {

// Store fragment, then make redirection continue (see redirect() above)
$target= $stored['flow'][$server] ?? null;
if ($target && ($fragment= $request->param(self::FRAGMENT))) {
if ($t= strstr($stored['flow'][$server], '#', true)) {
$stored['flow'][$server]= $t.'#'.$fragment;
} else {
$stored['flow'][$server].= '#'.$fragment;
}
// Caputre fragment, then continue redirection, see the script above
$flow['uri']= substr($flow['uri'], 0, strcspn($flow['uri'], '#')).'#'.$fragment;
$stored['flows'][$state]= $flow;

$session->register($this->namespace, $stored);
$session->transmit($response);
$response->send('document.location.replace(target)', 'text/javascript');
$response->send('document.location.replace(target);', 'text/javascript');
return null;
}

// Back from authentication redirect, upgrade request token to access token
// Handle previous session layout
if ($target || (($target= $stored['target'] ?? null) && ($server === $stored['oauth_token']))) {
unset($stored['flow'][$server]);
} else {

// Back from authentication redirect, upgrade request token to access token
$stored['token']= $this->request(
'/access_token',
$server,
$state,
['oauth_verifier' => $request->param('oauth_verifier')]
);

unset($stored['flows'][$state], $stored['flow'][$state]);
$session->register($this->namespace, $stored);
$session->transmit($response);

// Redirect to self
$this->finalize($response, $target);
return null;
// Redirect to self, using captured fragment if present
return $this->finalize($response, $flow['uri']);
}

throw new IllegalStateException(sprintf(
'Flow error, unknown server state %s expecting one of %s',
$server,
implode(', ', array_keys($stored['flow'] ?? [$stored['oauth_token'] => true]))
));
}
}
2 changes: 1 addition & 1 deletion src/main/php/web/auth/oauth/OAuth2Endpoint.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public function seed() { return $this->credentials->seed(); }
/**
* Returns authorization parameters
*
* @param [:string] $grant
* @param [:string] $auth
* @param [:string] $seed
* @return [:string]
*/
Expand Down
102 changes: 34 additions & 68 deletions src/main/php/web/auth/oauth/OAuth2Flow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,10 @@ public function refresh(array $claims) {
if (time() < $claims['expires']) return null;

// Refresh token
$result= $this->backend->acquire([
return ByAccessToken::from($this->backend->acquire([
'grant_type' => 'refresh_token',
'refresh_token' => $claims['refresh'],
]);
return new ByAccessToken(
$result['access_token'],
$result['token_type'] ?? 'Bearer',
$result['scope'] ?? null,
$result['expires_in'] ?? null,
$result['refresh_token'] ?? null,
$result['id_token'] ?? null
);
]));
}

/**
Expand All @@ -84,36 +76,35 @@ public function refresh(array $claims) {
* @throws lang.IllegalStateException
*/
public function authenticate($request, $response, $session) {
$stored= $session->value($this->namespace);
$stored= $session->value($this->namespace) ?? ['state' => []];

// 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
// We have an access token, remove and return an authenticated session. The
// authentication implementation registers the user and transmits the session.
if ($token= $stored['token'] ?? null) {
unset($stored['token']);
$session->register($this->namespace, $stored);

return new ByAccessToken(
$token['access_token'],
$token['token_type'] ?? 'Bearer',
$token['scope'] ?? null,
$token['expires_in'] ?? null,
$token['refresh_token'] ?? null,
$token['id_token'] ?? null
);
return ByAccessToken::from($token);
}

// Enter authentication flow, resolving callback URI against the curren request.
$uri= $this->url(true)->resolve($request);
$callback= $this->callback ? $uri->resolve($this->callback) : $this->service($uri);

// Start authorization flow to acquire an access token
$server= $request->param('state');
if (null === $server || null === $stored) {
// Check whether we are continuing an existing authentication flow based on the
// state given by the server and our session; or if we need to start a new one.
if (null === ($server= $request->param('state'))) {
$flow= null;
} else {
sscanf($server, self::STATE, $state, $fragment);
$flow= $this->flow($state, $stored);
}

if (null === $flow) {
$state= bin2hex($this->rand->bytes(16));
$seed= $this->backend->seed();

$stored??= ['flow' => []];
$stored['flow'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
$stored['flows'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
$session->register($this->namespace, $stored);
$session->transmit($response);

Expand All @@ -128,59 +119,34 @@ public function authenticate($request, $response, $session) {
$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.
$this->redirect($response, $target, sprintf('
var target = "%1$s";
var hash = document.location.hash.substring(1);

// is always passed as the last parameter to the authentication service.
$separator= self::FRAGMENT;
return $this->redirect($response, $target, <<<JS
var hash = document.location.hash;
if (hash) {
document.location.replace(target + "%2$s" + encodeURIComponent(hash));
document.location.replace('{$target}{$separator}' + encodeURIComponent(hash.substring(1)));
} else {
document.location.replace(target);
}',
$target,
self::FRAGMENT
));
return null;
}
document.location.replace('{$target}');
}
JS
);
} else {

// Continue authorization flow, handling previous session layout
$state= explode(self::FRAGMENT, $server);
if (
($target= $stored['flow'][$state[0]] ?? null) ||
(($target= $stored['target'] ?? null) && ($state[0] === $stored['state']))
) {
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
// Exchange the auth code for an access token, then remove the stored state.
$params= [
'grant_type' => 'authorization_code',
'code' => $request->param('code'),
'redirect_uri' => $callback,
'state' => $server
'state' => $state
];
$stored['token']= $this->backend->acquire($params, $seed);
$stored['token']= $this->backend->acquire($params, $flow['seed']);

unset($stored['flows'][$state], $stored['flow'][$state]);
$session->register($this->namespace, $stored);
$session->transmit($response);

// Redirect to self, using encoded fragment if present
$this->finalize($response, $uri.(isset($state[1]) ? '#'.urldecode($state[1]) : ''));
return null;
return $this->finalize($response, $flow['uri'].(isset($fragment) ? '#'.urldecode($fragment) : ''));
}

throw new IllegalStateException(sprintf(
'Flow error, unknown server state %s expecting one of %s',
$state[0],
implode(', ', array_keys($stored['flow'] ?? [$stored['state'] => true]))
));
}
}
9 changes: 9 additions & 0 deletions src/main/php/web/auth/oauth/OAuthFlow.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
abstract class OAuthFlow extends Flow {
protected $callback;

/** Locate flow stored in session based on a given state, handling deprecated session layouts */
protected function flow($state, $stored) {
return (
$stored['flows'][$state] ??
(isset($stored['flow'][$state]) ? ['uri' => $stored['flow'][$state], 'seed' => []] : null) ??
(isset($stored['target']) ? ['uri' => $stored['target'], 'seed' => []] : null)
);
}

/** @return ?util.URI */
public function callback() { return $this->callback; }

Expand Down
Loading
Loading