Skip to content

Commit 975af8e

Browse files
committed
OAuth2 PKCE validation and new endpoints
1 parent 22770c8 commit 975af8e

File tree

4 files changed

+206
-68
lines changed

4 files changed

+206
-68
lines changed

Tests/DefinitionTest.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,12 @@ public function testConstructFromArray() : void
195195
$original = [
196196
'boolean' => true,
197197
'integer' => 10,
198-
'string' => 'string',
198+
'string' => 'stringLn10',
199199
'enum' => 'primary_email',
200-
'array' => ['one' => 1, 'two' => 2, 'three' => 3],
200+
'array' => ['one' => false, 'two' => 2, 'three' => '3', 'four' => 1.23],
201201
'float' => 3.1415926,
202202
'ucEnum' => 'SCHEDULED',
203203
'intEnum' => 5,
204-
'class' => [['float' => 234.567], ],
205-
'classArray' => [['boolean' => false], ['integer' => 22], ['array' => [1,2,3]], ],
206204
];
207205
$fixture = new \Tests\Fixtures\TypeTest($original);
208206
$this->assertEquals($original, $fixture->getData());

Tools/Generator.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class Generator
2323

2424
public function __construct()
2525
{
26-
$this->nl = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? "\r\n" : "\n";
26+
$this->nl = 'WIN' === \strtoupper(\substr(PHP_OS, 0, 3)) ? "\r\n" : "\n";
2727
}
2828

2929
public function makeClasses(string $version, array $paths) : void

src/ConstantContact/Client.php

Lines changed: 147 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,26 @@
33
namespace PHPFUI\ConstantContact;
44

55
class Client
6-
{
6+
{
77
public string $accessToken = '';
88

99
public string $refreshToken = '';
1010

11-
private string $oauth2URL = 'https://idfed.constantcontact.com/as/token.oauth2';
11+
private string $oauth2URL = 'https://authz.constantcontact.com/oauth2/default/v1/token';
1212

13-
private string $lastError = '';
13+
private string $authorizeURL = 'https://authz.constantcontact.com/oauth2/default/v1/authorize';
14+
15+
private string $lastError = '';
1416

1517
private string $body = '';
1618

1719
private string $host = '';
1820

19-
private int $statusCode = 200;
21+
private int $statusCode = 200;
2022

21-
private array $scopes = [];
23+
private array $scopes = [];
2224

23-
private array $validScopes = ['account_read', 'account_update', 'contact_data', 'campaign_data', ];
25+
private array $validScopes = ['account_read', 'account_update', 'contact_data', 'campaign_data', 'offline_access', ];
2426

2527
private string $next = '';
2628

@@ -30,7 +32,7 @@ class Client
3032
* By default, all scopes are enabled. You can remove any, or
3133
* set new ones.
3234
*/
33-
public function __construct(private string $clientAPIKey, private string $clientSecret, private string $redirectURI = 'https://localhost/')
35+
public function __construct(private string $clientAPIKey, private string $clientSecret, private string $redirectURI = 'https://localhost/', private bool $PKCE = true)
3436
{
3537
// default to all scopes
3638
$this->scopes = \array_flip($this->validScopes);
@@ -64,7 +66,7 @@ public function setHost(string $host) : self
6466
return $this;
6567
}
6668

67-
public function addScope(string $scope) : self
69+
public function addScope(string $scope) : self
6870
{
6971
if (! \in_array($scope, $this->validScopes))
7072
{
@@ -75,14 +77,14 @@ public function addScope(string $scope) : self
7577
return $this;
7678
}
7779

78-
public function removeScope(string $scope) : self
80+
public function removeScope(string $scope) : self
7981
{
8082
unset($this->scopes[$scope]);
8183

8284
return $this;
8385
}
8486

85-
public function setScopes(array $scopes) : self
87+
public function setScopes(array $scopes) : self
8688
{
8789
$this->scopes = [];
8890

@@ -104,37 +106,89 @@ public function getStatusCode() : int
104106
return $this->statusCode;
105107
}
106108

107-
/**
108-
* Generate the URL an account owner would use to allow your app
109-
* to access their account.
110-
*
111-
* After visiting the URL, the account owner is prompted to log in and allow your app to access their account.
112-
* They are then redirected to your redirect URL with the authorization code appended as a query parameter. e.g.:
113-
* http://localhost:8888/?code={authorization_code}
114-
*/
115-
public function getAuthorizationURL() : string
109+
/**
110+
* Generate the URL an account owner would use to allow your app
111+
* to access their account.
112+
*
113+
* After visiting the URL, the account owner is prompted to log in and allow your app to access their account.
114+
* They are then redirected to your redirect URL with the authorization code appended as a query parameter. e.g.:
115+
* http://localhost:8888/?code={authorization_code}
116+
*/
117+
public function getAuthorizationURL() : string
116118
{
117-
$scopes = \implode('%2B', \array_keys($this->scopes));
118-
$authURL = "https://api.cc.email/v3/idfed?client_id={$this->clientAPIKey}&response_type=code&redirect_uri={$this->redirectURI}&scope={$scopes}";
119+
$scopes = \implode('+', \array_keys($this->scopes));
120+
121+
$state = \bin2hex(\random_bytes(8));
122+
$_SESSION['PHPFUI\ConstantContact\state'] = $state;
123+
$params = [
124+
'response_type' => 'code',
125+
'client_id' => $this->clientAPIKey,
126+
'redirect_uri' => $this->redirectURI,
127+
'scope' => $scopes,
128+
'state' => $state,
129+
];
130+
131+
if ($this->PKCE)
132+
{
133+
[$code_verifier, $code_challenge] = $this->codeChallenge();
119134

120-
return $authURL;
121-
}
135+
// Store generated random state and code challenge based on RFC 7636
136+
// https://datatracker.ietf.org/doc/html/rfc7636#section-6.1
137+
$_SESSION['PHPFUI\ConstantContact\code_verifier'] = $code_verifier;
138+
$params['code_challenge'] = $code_challenge;
139+
$params['code_challenge_method'] = 'S256';
140+
}
122141

123-
/**
124-
* Exchange an authorization code for an access token.
125-
*
126-
* Make this call by passing in the code present when the account owner is redirected back to you.
127-
* The response will contain an 'access_token' and 'refresh_token'
128-
*
129-
* @param string $code - Authorization Code
130-
*/
131-
public function acquireAccessToken(string $code) : bool
142+
$url = $this->authorizeURL . '?' . \str_replace('%2B', '+', \http_build_query($params)); // hack %2B to + for stupid CC API bug
143+
144+
return $url;
145+
}
146+
147+
/**
148+
* Exchange an authorization code for an access token.
149+
*
150+
* Make this call by passing in the code present when the account owner is redirected back to you.
151+
* The response will contain an 'access_token' and 'refresh_token'
152+
*
153+
* @param array of get parameters passed to redirect URL
154+
*/
155+
public function acquireAccessToken(array $parameters) : bool
132156
{
157+
if (isset($parameters['error']))
158+
{
159+
$this->statusCode = 0;
160+
$this->lastError = $parameters['error'] . ': ' . ($parameters['error_description'] ?? 'Undefined');
161+
162+
return false;
163+
}
164+
165+
$expectedState = $_SESSION['PHPFUI\ConstantContact\state'];
166+
unset($_SESSION['PHPFUI\ConstantContact\state']);
167+
168+
if (($parameters['state'] ?? 'undefined') != $expectedState)
169+
{
170+
$this->statusCode = 0;
171+
$this->lastError = 'state is not correct';
172+
173+
return false;
174+
}
175+
133176
// Use cURL to get access token and refresh token
134177
$ch = \curl_init();
135178

136179
// Create full request URL
137-
$url = "{$this->oauth2URL}?code={$code}&redirect_uri={$this->redirectURI}&grant_type=authorization_code";
180+
$params = [
181+
'code' => $parameters['code'],
182+
'redirect_uri' => $this->redirectURI,
183+
'grant_type' => 'authorization_code',
184+
];
185+
186+
if ($this->PKCE)
187+
{
188+
$params['code_verifier'] = $_SESSION['PHPFUI\ConstantContact\code_verifier'];
189+
unset($_SESSION['PHPFUI\ConstantContact\code_verifier']);
190+
}
191+
$url = $this->oauth2URL . '?' . \http_build_query($params);
138192
\curl_setopt($ch, CURLOPT_URL, $url);
139193

140194
$this->setAuthorization($ch);
@@ -145,18 +199,22 @@ public function acquireAccessToken(string $code) : bool
145199
return $this->exec($ch);
146200
}
147201

148-
/**
149-
* Refresh the access token.
150-
*
151-
* @return string new access token or 'Error' for error
152-
*/
153-
public function refreshToken() : string
202+
/**
203+
* Refresh the access token.
204+
*/
205+
public function refreshToken() : bool
154206
{
155207
// Use cURL to get a new access token and refresh token
156208
$ch = \curl_init();
157209

158210
// Create full request URL
159-
$url = "{$this->oauth2URL}?refresh_token={$this->refreshToken}&grant_type=refresh_token";
211+
$params = [
212+
'refresh_token' => $this->refreshToken,
213+
'grant_type' => 'refresh_token',
214+
'redirect_uri' => $this->redirectURI,
215+
];
216+
217+
$url = $this->oauth2URL . '?' . \http_build_query($params);
160218
\curl_setopt($ch, CURLOPT_URL, $url);
161219

162220
$this->setAuthorization($ch);
@@ -274,7 +332,7 @@ public function post(string $url, array $parameters) : array
274332
return [];
275333
}
276334

277-
private function exec(\CurlHandle $ch) : bool
335+
private function exec(\CurlHandle $ch) : bool
278336
{
279337
\curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
280338

@@ -286,13 +344,21 @@ private function exec(\CurlHandle $ch) : bool
286344
if ($result)
287345
{
288346
$data = \json_decode($result, true);
289-
$retVal = isset($data['access_token'], $data['refresh_token']);
290-
$this->accessToken = $data['access_token'] ?? 'Error';
291-
$this->refreshToken = $data['refresh_token'] ?? 'Error';
347+
348+
if (isset($data['error']))
349+
{
350+
// [error_description] => Cannot supply multiple client credentials.
351+
// Use one of the following: credentials in the Authorization header,
352+
// credentials in the post body,
353+
// or a client_assertion in the post body.
354+
$this->lastError = $data['error'] . ': ' . ($data['error_description'] ?? 'Undefined');
355+
}
356+
$this->accessToken = $data['access_token'] ?? '';
357+
$this->refreshToken = $data['refresh_token'] ?? '';
292358

293359
\curl_close($ch);
294360

295-
return $retVal;
361+
return isset($data['access_token'], $data['refresh_token']);
296362
}
297363

298364
$this->statusCode = \curl_errno($ch);
@@ -302,7 +368,7 @@ private function exec(\CurlHandle $ch) : bool
302368
return false;
303369
}
304370

305-
private function setAuthorization(\CurlHandle $ch) : void
371+
private function setAuthorization(\CurlHandle $ch) : void
306372
{
307373
// Set authorization header
308374
// Make string of "API_KEY:SECRET"
@@ -345,4 +411,38 @@ private function process(\GuzzleHttp\Psr7\Response $response) : array
345411

346412
return [];
347413
}
348-
}
414+
415+
/**
416+
* Generate code_verifier and code_challenge for rfc7636 PKCE.
417+
* https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
418+
*
419+
* @return array [code_verifier, code_challenge].
420+
*/
421+
private function codeChallenge(?string $code_verifier = null) : array
422+
{
423+
$gen = static function()
424+
{
425+
$strings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
426+
$length = \random_int(43, 128);
427+
428+
for ($i = 0; $i < $length; $i++)
429+
{
430+
yield $strings[\random_int(0, 65)];
431+
}
432+
};
433+
434+
$code = $code_verifier ?? \implode('', \iterator_to_array($gen()));
435+
436+
if (! \preg_match('/[A-Za-z0-9-._~]{43,128}/', $code))
437+
{
438+
return ['', ''];
439+
}
440+
441+
return [$code, $this->base64url_encode(\pack('H*', \hash('sha256', $code)))];
442+
}
443+
444+
private function base64url_encode(string $data) : string
445+
{
446+
return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
447+
}
448+
}

0 commit comments

Comments
 (0)