3
3
namespace PHPFUI \ConstantContact ;
4
4
5
5
class Client
6
- {
6
+ {
7
7
public string $ accessToken = '' ;
8
8
9
9
public string $ refreshToken = '' ;
10
10
11
- private string $ oauth2URL = 'https://idfed .constantcontact.com/as/ token.oauth2 ' ;
11
+ private string $ oauth2URL = 'https://authz .constantcontact.com/oauth2/default/v1/ token ' ;
12
12
13
- private string $ lastError = '' ;
13
+ private string $ authorizeURL = 'https://authz.constantcontact.com/oauth2/default/v1/authorize ' ;
14
+
15
+ private string $ lastError = '' ;
14
16
15
17
private string $ body = '' ;
16
18
17
19
private string $ host = '' ;
18
20
19
- private int $ statusCode = 200 ;
21
+ private int $ statusCode = 200 ;
20
22
21
- private array $ scopes = [];
23
+ private array $ scopes = [];
22
24
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 ' , ];
24
26
25
27
private string $ next = '' ;
26
28
@@ -30,7 +32,7 @@ class Client
30
32
* By default, all scopes are enabled. You can remove any, or
31
33
* set new ones.
32
34
*/
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 )
34
36
{
35
37
// default to all scopes
36
38
$ this ->scopes = \array_flip ($ this ->validScopes );
@@ -64,7 +66,7 @@ public function setHost(string $host) : self
64
66
return $ this ;
65
67
}
66
68
67
- public function addScope (string $ scope ) : self
69
+ public function addScope (string $ scope ) : self
68
70
{
69
71
if (! \in_array ($ scope , $ this ->validScopes ))
70
72
{
@@ -75,14 +77,14 @@ public function addScope(string $scope) : self
75
77
return $ this ;
76
78
}
77
79
78
- public function removeScope (string $ scope ) : self
80
+ public function removeScope (string $ scope ) : self
79
81
{
80
82
unset($ this ->scopes [$ scope ]);
81
83
82
84
return $ this ;
83
85
}
84
86
85
- public function setScopes (array $ scopes ) : self
87
+ public function setScopes (array $ scopes ) : self
86
88
{
87
89
$ this ->scopes = [];
88
90
@@ -104,37 +106,89 @@ public function getStatusCode() : int
104
106
return $ this ->statusCode ;
105
107
}
106
108
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
116
118
{
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 ();
119
134
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
+ }
122
141
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
132
156
{
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
+
133
176
// Use cURL to get access token and refresh token
134
177
$ ch = \curl_init ();
135
178
136
179
// 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 );
138
192
\curl_setopt ($ ch , CURLOPT_URL , $ url );
139
193
140
194
$ this ->setAuthorization ($ ch );
@@ -145,18 +199,22 @@ public function acquireAccessToken(string $code) : bool
145
199
return $ this ->exec ($ ch );
146
200
}
147
201
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
154
206
{
155
207
// Use cURL to get a new access token and refresh token
156
208
$ ch = \curl_init ();
157
209
158
210
// 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 );
160
218
\curl_setopt ($ ch , CURLOPT_URL , $ url );
161
219
162
220
$ this ->setAuthorization ($ ch );
@@ -274,7 +332,7 @@ public function post(string $url, array $parameters) : array
274
332
return [];
275
333
}
276
334
277
- private function exec (\CurlHandle $ ch ) : bool
335
+ private function exec (\CurlHandle $ ch ) : bool
278
336
{
279
337
\curl_setopt ($ ch , CURLOPT_RETURNTRANSFER , true );
280
338
@@ -286,13 +344,21 @@ private function exec(\CurlHandle $ch) : bool
286
344
if ($ result )
287
345
{
288
346
$ 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 ' ] ?? '' ;
292
358
293
359
\curl_close ($ ch );
294
360
295
- return $ retVal ;
361
+ return isset ( $ data [ ' access_token ' ], $ data [ ' refresh_token ' ]) ;
296
362
}
297
363
298
364
$ this ->statusCode = \curl_errno ($ ch );
@@ -302,7 +368,7 @@ private function exec(\CurlHandle $ch) : bool
302
368
return false ;
303
369
}
304
370
305
- private function setAuthorization (\CurlHandle $ ch ) : void
371
+ private function setAuthorization (\CurlHandle $ ch ) : void
306
372
{
307
373
// Set authorization header
308
374
// Make string of "API_KEY:SECRET"
@@ -345,4 +411,38 @@ private function process(\GuzzleHttp\Psr7\Response $response) : array
345
411
346
412
return [];
347
413
}
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