33namespace PHPFUI \ConstantContact ;
44
55class 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