Skip to content

Commit 4e1098c

Browse files
authored
Merge pull request #37 from xp-forge/refactor/oauth
Refactor OAuth1 & OAuth2 flows to simplifiy their implementation
2 parents 24f93e0 + 9e88e27 commit 4e1098c

8 files changed

Lines changed: 149 additions & 158 deletions

File tree

src/main/php/web/auth/Flow.class.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

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

910
private $url= null;

src/main/php/web/auth/oauth/ByAccessToken.class.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ public function __construct($token, $type= 'Bearer', $scope= null, $expires= nul
2424
$this->id= null === $id ? null : ($id instanceof Secret ? $id : new Secret($id));
2525
}
2626

27+
/**
28+
* Creates an instance resulting from access token response
29+
*
30+
* @see https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
31+
*/
32+
public static function from(array $result): self {
33+
return new self(
34+
$result['access_token'],
35+
$result['token_type'] ?? 'Bearer',
36+
$result['scope'] ?? null,
37+
$result['expires_in'] ?? null,
38+
$result['refresh_token'] ?? null,
39+
$result['id_token'] ?? null
40+
);
41+
}
42+
2743
/** @return util.Secret */
2844
public function token() { return $this->token; }
2945

src/main/php/web/auth/oauth/OAuth1Flow.class.php

Lines changed: 39 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,10 @@ protected function request($path, $token= null, $params= []) {
7676
* @throws lang.IllegalStateException
7777
*/
7878
public function authenticate($request, $response, $session) {
79-
$stored= $session->value($this->namespace);
79+
$stored= $session->value($this->namespace) ?? ['flows' => []];
8080

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

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

96-
// Start authenticaton flow by obtaining request token and store for later use
97-
if (null === $server || null === $stored) {
98-
$token= $this->request('/request_token', null, ['oauth_callback' => $callback])['oauth_token'];
99-
$stored??= ['flow' => []];
100-
$stored['flow'][$token]= (string)$uri;
97+
// Check whether we are continuing an existing authentication flow based on the
98+
// state given by the server and our session; or if we need to start a new one.
99+
if (null === ($state= $request->param('oauth_token'))) {
100+
$flow= null;
101+
} else {
102+
$flow= $this->flow($state, $stored);
103+
}
104+
105+
if (null === $flow) {
106+
$state= $this->request('/request_token', null, ['oauth_callback' => $callback])['oauth_token'];
107+
108+
$stored['flows'][$state]= ['uri' => (string)$uri, 'seed' => []];
101109
$session->register($this->namespace, $stored);
102110
$session->transmit($response);
103111

104112
// Redirect the user to the authorization page
105-
$target= sprintf(
106-
'%s/authenticate?oauth_token=%s&oauth_callback=%s',
107-
$this->service,
108-
urlencode($token),
109-
urlencode($callback)
110-
);
113+
$token= urlencode($state);
114+
$target= sprintf('%s/authenticate?oauth_token=%s&oauth_callback=%s', $this->service, $token, urlencode($callback));
111115

112116
// If a URL fragment is present, call ourselves to capture it inside the
113117
// session; otherwise redirect the OAuth authentication service directly.
114-
$this->redirect($response, $target, sprintf('
115-
var target = "%1$s";
116-
var hash = document.location.hash.substring(1);
117-
118+
$separator= self::FRAGMENT;
119+
return $this->redirect($response, $target, <<<JS
120+
var hash = document.location.hash;
118121
if (hash) {
119-
var s = document.createElement("script");
120-
s.src = "%2$s?oauth_token=%4$s&%3$s=" + encodeURIComponent(hash) + "&" + Math.random();
122+
var target = '{$target}';
123+
var s = document.createElement('script');
124+
s.src = '{$uri}?oauth_token={$token}&{$separator}=' + encodeURIComponent(hash.substring(1)) + '&' + Math.random();
121125
document.body.appendChild(s);
122126
} else {
123-
document.location.replace(target);
124-
}',
125-
$target,
126-
$uri,
127-
self::FRAGMENT,
128-
urlencode($token)
129-
));
130-
return null;
131-
}
127+
document.location.replace('{$target}');
128+
}
129+
JS
130+
);
131+
} else if ($fragment= $request->param(self::FRAGMENT)) {
132132

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

142137
$session->register($this->namespace, $stored);
143138
$session->transmit($response);
144-
$response->send('document.location.replace(target)', 'text/javascript');
139+
$response->send('document.location.replace(target);', 'text/javascript');
145140
return null;
146-
}
147-
148-
// Back from authentication redirect, upgrade request token to access token
149-
// Handle previous session layout
150-
if ($target || (($target= $stored['target'] ?? null) && ($server === $stored['oauth_token']))) {
151-
unset($stored['flow'][$server]);
141+
} else {
152142

153143
// Back from authentication redirect, upgrade request token to access token
154144
$stored['token']= $this->request(
155145
'/access_token',
156-
$server,
146+
$state,
157147
['oauth_verifier' => $request->param('oauth_verifier')]
158148
);
149+
150+
unset($stored['flows'][$state], $stored['flow'][$state]);
159151
$session->register($this->namespace, $stored);
160152
$session->transmit($response);
161153

162-
// Redirect to self
163-
$this->finalize($response, $target);
164-
return null;
154+
// Redirect to self, using captured fragment if present
155+
return $this->finalize($response, $flow['uri']);
165156
}
166-
167-
throw new IllegalStateException(sprintf(
168-
'Flow error, unknown server state %s expecting one of %s',
169-
$server,
170-
implode(', ', array_keys($stored['flow'] ?? [$stored['oauth_token'] => true]))
171-
));
172157
}
173158
}

src/main/php/web/auth/oauth/OAuth2Endpoint.class.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public function seed() { return $this->credentials->seed(); }
8383
/**
8484
* Returns authorization parameters
8585
*
86-
* @param [:string] $grant
86+
* @param [:string] $auth
8787
* @param [:string] $seed
8888
* @return [:string]
8989
*/

src/main/php/web/auth/oauth/OAuth2Flow.class.php

Lines changed: 34 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -60,18 +60,10 @@ public function refresh(array $claims) {
6060
if (time() < $claims['expires']) return null;
6161

6262
// Refresh token
63-
$result= $this->backend->acquire([
63+
return ByAccessToken::from($this->backend->acquire([
6464
'grant_type' => 'refresh_token',
6565
'refresh_token' => $claims['refresh'],
66-
]);
67-
return new ByAccessToken(
68-
$result['access_token'],
69-
$result['token_type'] ?? 'Bearer',
70-
$result['scope'] ?? null,
71-
$result['expires_in'] ?? null,
72-
$result['refresh_token'] ?? null,
73-
$result['id_token'] ?? null
74-
);
66+
]));
7567
}
7668

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

89-
// We have an access token, reset state and return an authenticated session
90-
// See https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
91-
// and https://tools.ietf.org/html/rfc6749#section-5.1
81+
// We have an access token, remove and return an authenticated session. The
82+
// authentication implementation registers the user and transmits the session.
9283
if ($token= $stored['token'] ?? null) {
9384
unset($stored['token']);
9485
$session->register($this->namespace, $stored);
9586

96-
return new ByAccessToken(
97-
$token['access_token'],
98-
$token['token_type'] ?? 'Bearer',
99-
$token['scope'] ?? null,
100-
$token['expires_in'] ?? null,
101-
$token['refresh_token'] ?? null,
102-
$token['id_token'] ?? null
103-
);
87+
return ByAccessToken::from($token);
10488
}
10589

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

109-
// Start authorization flow to acquire an access token
110-
$server= $request->param('state');
111-
if (null === $server || null === $stored) {
94+
// Check whether we are continuing an existing authentication flow based on the
95+
// state given by the server and our session; or if we need to start a new one.
96+
if (null === ($server= $request->param('state'))) {
97+
$flow= null;
98+
} else {
99+
sscanf($server, self::STATE, $state, $fragment);
100+
$flow= $this->flow($state, $stored);
101+
}
102+
103+
if (null === $flow) {
112104
$state= bin2hex($this->rand->bytes(16));
113105
$seed= $this->backend->seed();
114106

115-
$stored??= ['flow' => []];
116-
$stored['flow'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
107+
$stored['flows'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
117108
$session->register($this->namespace, $stored);
118109
$session->transmit($response);
119110

@@ -128,59 +119,34 @@ public function authenticate($request, $response, $session) {
128119
$target= $this->auth->using()->params($this->backend->pass($params, $seed))->create();
129120

130121
// If a URL fragment is present, append it to the state parameter, which
131-
// is passed as the last parameter to the authentication service.
132-
$this->redirect($response, $target, sprintf('
133-
var target = "%1$s";
134-
var hash = document.location.hash.substring(1);
135-
122+
// is always passed as the last parameter to the authentication service.
123+
$separator= self::FRAGMENT;
124+
return $this->redirect($response, $target, <<<JS
125+
var hash = document.location.hash;
136126
if (hash) {
137-
document.location.replace(target + "%2$s" + encodeURIComponent(hash));
127+
document.location.replace('{$target}{$separator}' + encodeURIComponent(hash.substring(1)));
138128
} else {
139-
document.location.replace(target);
140-
}',
141-
$target,
142-
self::FRAGMENT
143-
));
144-
return null;
145-
}
129+
document.location.replace('{$target}');
130+
}
131+
JS
132+
);
133+
} else {
146134

147-
// Continue authorization flow, handling previous session layout
148-
$state= explode(self::FRAGMENT, $server);
149-
if (
150-
($target= $stored['flow'][$state[0]] ?? null) ||
151-
(($target= $stored['target'] ?? null) && ($state[0] === $stored['state']))
152-
) {
153-
unset($stored['flow'][$state[0]]);
154-
155-
// Target is an array for old session layout and during transition
156-
if (is_array($target)) {
157-
$uri= $target['uri'];
158-
$seed= $target['seed'];
159-
} else {
160-
$uri= $target;
161-
$seed= [];
162-
}
163-
164-
// Exchange the auth code for an access token
135+
// Exchange the auth code for an access token, then remove the stored state.
165136
$params= [
166137
'grant_type' => 'authorization_code',
167138
'code' => $request->param('code'),
168139
'redirect_uri' => $callback,
169-
'state' => $server
140+
'state' => $state
170141
];
171-
$stored['token']= $this->backend->acquire($params, $seed);
142+
$stored['token']= $this->backend->acquire($params, $flow['seed']);
143+
144+
unset($stored['flows'][$state], $stored['flow'][$state]);
172145
$session->register($this->namespace, $stored);
173146
$session->transmit($response);
174147

175148
// Redirect to self, using encoded fragment if present
176-
$this->finalize($response, $uri.(isset($state[1]) ? '#'.urldecode($state[1]) : ''));
177-
return null;
149+
return $this->finalize($response, $flow['uri'].(isset($fragment) ? '#'.urldecode($fragment) : ''));
178150
}
179-
180-
throw new IllegalStateException(sprintf(
181-
'Flow error, unknown server state %s expecting one of %s',
182-
$state[0],
183-
implode(', ', array_keys($stored['flow'] ?? [$stored['state'] => true]))
184-
));
185151
}
186152
}

src/main/php/web/auth/oauth/OAuthFlow.class.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@
66
abstract class OAuthFlow extends Flow {
77
protected $callback;
88

9+
/** Locate flow stored in session based on a given state, handling deprecated session layouts */
10+
protected function flow($state, $stored) {
11+
return (
12+
$stored['flows'][$state] ??
13+
(isset($stored['flow'][$state]) ? ['uri' => $stored['flow'][$state], 'seed' => []] : null) ??
14+
(isset($stored['target']) ? ['uri' => $stored['target'], 'seed' => []] : null)
15+
);
16+
}
17+
918
/** @return ?util.URI */
1019
public function callback() { return $this->callback; }
1120

0 commit comments

Comments
 (0)