Skip to content

Commit b54c0ec

Browse files
committed
Refactor OAuth1 & OAuth2 flows to simplifiy their implementation
1 parent af916d3 commit b54c0ec

7 files changed

Lines changed: 143 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: 41 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,71 @@ 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+
// Handle deprecated session layouts from previous library versions.
100+
$state= $request->param('oauth_token');
101+
$flow= (
102+
$stored['flows'][$state] ??
103+
(isset($stored['flow'][$state]) ? ['uri' => $stored['flow'][$state], 'seed' => []] : null) ??
104+
(isset($stored['target']) ? ['uri' => $stored['target'], 'seed' => []] : null)
105+
);
106+
107+
if (null === $flow) {
108+
$state= $this->request('/request_token', null, ['oauth_callback' => $callback])['oauth_token'];
109+
110+
$stored['flows'][$state]= ['uri' => (string)$uri, 'seed' => []];
101111
$session->register($this->namespace, $stored);
102112
$session->transmit($response);
103113

104114
// 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-
);
115+
$token= urlencode($state);
116+
$target= sprintf('%s/authenticate?oauth_token=%s&oauth_callback=%s', $this->service, $token, urlencode($callback));
111117

112118
// If a URL fragment is present, call ourselves to capture it inside the
113119
// 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-
120+
$separator= self::FRAGMENT;
121+
return $this->redirect($response, $target, <<<JS
122+
var hash = document.location.hash;
118123
if (hash) {
119-
var s = document.createElement("script");
120-
s.src = "%2$s?oauth_token=%4$s&%3$s=" + encodeURIComponent(hash) + "&" + Math.random();
124+
var target = '{$target}';
125+
var s = document.createElement('script');
126+
s.src = '{$uri}?oauth_token={$token}&{$separator}=' + encodeURIComponent(hash.substring(1)) + '&' + Math.random();
121127
document.body.appendChild(s);
122128
} else {
123-
document.location.replace(target);
124-
}',
125-
$target,
126-
$uri,
127-
self::FRAGMENT,
128-
urlencode($token)
129-
));
130-
return null;
131-
}
129+
document.location.replace('{$target}');
130+
}
131+
JS
132+
);
133+
} else if ($fragment= $request->param(self::FRAGMENT)) {
132134

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-
}
135+
// Caputre fragment, then continue redirection, see the script above
136+
$flow['uri']= substr($flow['uri'], 0, strcspn($flow['uri'], '#')).'#'.$fragment;
137+
$stored['flows'][$state]= $flow;
141138

142139
$session->register($this->namespace, $stored);
143140
$session->transmit($response);
144-
$response->send('document.location.replace(target)', 'text/javascript');
141+
$response->send('document.location.replace(target);', 'text/javascript');
145142
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]);
143+
} else {
152144

153145
// Back from authentication redirect, upgrade request token to access token
154146
$stored['token']= $this->request(
155147
'/access_token',
156-
$server,
148+
$state,
157149
['oauth_verifier' => $request->param('oauth_verifier')]
158150
);
151+
152+
unset($stored['flows'][$state], $stored['flow'][$state]);
159153
$session->register($this->namespace, $stored);
160154
$session->transmit($response);
161155

162-
// Redirect to self
163-
$this->finalize($response, $target);
164-
return null;
156+
// Redirect to self, using captured fragment if present
157+
return $this->finalize($response, $flow['uri']);
165158
}
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-
));
172159
}
173160
}

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: 35 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,36 @@ 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+
// Handle deprecated session layouts from previous library versions.
97+
sscanf($request->param('state') ?? '', self::STATE, $state, $fragment);
98+
$flow= (
99+
$stored['flows'][$state] ??
100+
(isset($stored['flow'][$state]) ? ['uri' => $stored['flow'][$state], 'seed' => []] : null) ??
101+
(isset($stored['target']) ? ['uri' => $stored['target'], 'seed' => []] : null)
102+
);
103+
104+
if (null === $flow) {
112105
$state= bin2hex($this->rand->bytes(16));
113106
$seed= $this->backend->seed();
114107

115-
$stored??= ['flow' => []];
116-
$stored['flow'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
108+
$stored['flows'][$state]= ['uri' => (string)$uri, 'seed' => $seed];
117109
$session->register($this->namespace, $stored);
118110
$session->transmit($response);
119111

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

130122
// 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-
123+
// is always passed as the last parameter to the authentication service.
124+
$separator= self::FRAGMENT;
125+
return $this->redirect($response, $target, <<<JS
126+
var hash = document.location.hash;
136127
if (hash) {
137-
document.location.replace(target + "%2$s" + encodeURIComponent(hash));
128+
document.location.replace('{$target}{$separator}' + encodeURIComponent(hash.substring(1)));
138129
} else {
139-
document.location.replace(target);
140-
}',
141-
$target,
142-
self::FRAGMENT
143-
));
144-
return null;
145-
}
130+
document.location.replace('{$target}');
131+
}
132+
JS
133+
);
134+
} else {
146135

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
136+
// Exchange the auth code for an access token, then remove the stored state.
165137
$params= [
166138
'grant_type' => 'authorization_code',
167139
'code' => $request->param('code'),
168140
'redirect_uri' => $callback,
169-
'state' => $server
141+
'state' => $state
170142
];
171-
$stored['token']= $this->backend->acquire($params, $seed);
143+
$stored['token']= $this->backend->acquire($params, $flow['seed']);
144+
145+
unset($stored['flows'][$state], $stored['flow'][$state]);
172146
$session->register($this->namespace, $stored);
173147
$session->transmit($response);
174148

175149
// Redirect to self, using encoded fragment if present
176-
$this->finalize($response, $uri.(isset($state[1]) ? '#'.urldecode($state[1]) : ''));
177-
return null;
150+
return $this->finalize($response, $flow['uri'].(isset($fragment) ? '#'.urldecode($fragment) : ''));
178151
}
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-
));
185152
}
186153
}

0 commit comments

Comments
 (0)