@@ -31,6 +31,7 @@ class CustomerAccountClient {
31
31
// OAuth
32
32
static let customerAccountsAudience = " 30243aa5-17c1-465a-8493-944bcc4e88aa "
33
33
static let authorizationCodeGrantType = " authorization_code "
34
+ static let refreshTokenGrantType = " refresh_token "
34
35
static let tokenExchangeGrantType = " urn:ietf:params:oauth:grant-type:token-exchange "
35
36
static let accessTokenSubjectTokenType = " urn:ietf:params:oauth:token-type:access_token "
36
37
static let customerAPIScope = " https://api.customers.com/auth/customer.graphql "
@@ -44,13 +45,17 @@ class CustomerAccountClient {
44
45
private let redirectUri : String
45
46
private let customerAccountsBaseUrl : String
46
47
private let jsonDecoder : JSONDecoder = JSONDecoder ( )
48
+ private let accessTokenExpirationManager : AccessTokenExpirationManager = AccessTokenExpirationManager . shared
47
49
48
50
// Note: store tokens in Keychain in any production app
49
51
internal var refreshToken : String ?
50
52
internal var idToken : String ?
51
53
internal var accessToken : String ?
52
54
internal var sfApiAccessToken : String ?
53
55
56
+ @Published
57
+ var authenticated : Bool = false
58
+
54
59
init ( ) {
55
60
guard
56
61
let infoPlist = Bundle . main. infoDictionary,
@@ -67,6 +72,26 @@ class CustomerAccountClient {
67
72
self . customerAccountsBaseUrl = " https://shopify.com/ \( shopId) "
68
73
}
69
74
75
+ func isAuthenticated( ) -> Bool {
76
+ return self . authenticated
77
+ }
78
+
79
+ func getSfApiAccessToken( ) -> String ? {
80
+ return self . sfApiAccessToken
81
+ }
82
+
83
+ func getAccessToken( ) -> String ? {
84
+ return self . accessToken
85
+ }
86
+
87
+ func getIdToken( ) -> String ? {
88
+ return self . idToken
89
+ }
90
+
91
+ func getRefreshToken( ) -> String ? {
92
+ return self . refreshToken
93
+ }
94
+
70
95
func getRedirectUri( ) -> String {
71
96
return self . redirectUri
72
97
}
@@ -115,8 +140,8 @@ class CustomerAccountClient {
115
140
return
116
141
}
117
142
118
- if authData. state != state {
119
- callback ( nil , " State param does not match " )
143
+ if authData. state != state {
144
+ callback ( nil , " State param does not match: \( state ) " )
120
145
return
121
146
}
122
147
@@ -125,6 +150,79 @@ class CustomerAccountClient {
125
150
}
126
151
}
127
152
153
+ func logout( idToken: String , callback: @escaping ( String ? , String ? ) -> Void ) {
154
+ guard let url = URL ( string: " https://shopify.com/authentication/ \( self . shopId) /logout " ) else {
155
+ return
156
+ }
157
+
158
+ let params : [ String : String ] = [
159
+ " id_token_hint " : idToken,
160
+ " post_logout_redirect_uri " : self . redirectUri
161
+ ]
162
+
163
+ executeRequest (
164
+ url: url,
165
+ method: " GET " ,
166
+ query: params,
167
+ completionHandler: { _, response, _ in
168
+ let httpResponse = response as? HTTPURLResponse
169
+ guard httpResponse != nil , httpResponse? . statusCode == 200 else {
170
+ callback ( nil , " Failed to logout " )
171
+ return
172
+ }
173
+
174
+ self . resetAuthentication ( )
175
+
176
+ callback ( " Logged out successfully " , nil )
177
+ }
178
+ )
179
+ }
180
+
181
+ func resetAuthentication( ) {
182
+ self . refreshToken = nil
183
+ self . accessToken = nil
184
+ self . sfApiAccessToken = nil
185
+ self . authenticated = false
186
+ }
187
+
188
+ func refreshAccessToken( refreshToken: String , callback: @escaping ( String ? , String ? ) -> Void ) {
189
+ guard let url = URL ( string: " \( self . customerAccountsBaseUrl) /auth/oauth/token " ) else {
190
+ return
191
+ }
192
+
193
+ let params : [ String : String ] = [
194
+ " client_id " : clientId,
195
+ " grant_type " : CustomerAccountClient . refreshTokenGrantType,
196
+ " refresh_token " : self . refreshToken!
197
+ ]
198
+
199
+ executeRequest (
200
+ url: url,
201
+ headers: [
202
+ " Content-Type " : CustomerAccountClient . formUrlEncoded
203
+ ] ,
204
+ body: encodeToFormURLEncoded ( parameters: params) ,
205
+ completionHandler: { data, _, _ in
206
+ guard let tokenResponse = self . jsonDecoder. decodeOrNil ( CustomerAccountRefreshedTokenResponse . self, from: data!) else {
207
+ callback ( nil , " Couldn't decode refresh access token response " )
208
+ return
209
+ }
210
+
211
+ self . refreshToken = tokenResponse. refreshToken
212
+ self . accessToken = tokenResponse. accessToken
213
+ self . accessTokenExpirationManager. addAccessToken ( accessToken: tokenResponse. accessToken, expiresIn: tokenResponse. expiresIn)
214
+ self . exchangeForStorefrontCustomerAccessToken (
215
+ customerAccessToken: tokenResponse. accessToken,
216
+ refreshToken: tokenResponse. refreshToken,
217
+ callback: callback
218
+ )
219
+ self . authenticated = true
220
+
221
+ callback ( tokenResponse. accessToken, nil )
222
+ }
223
+ )
224
+ }
225
+
128
226
/// Requests accessToken with the authorization code and code verifier
129
227
private func requestAccessToken( code: String , codeVerifier: String , callback: @escaping ( String ? , String ? ) -> Void ) {
130
228
guard let url = URL ( string: " \( self . customerAccountsBaseUrl) /auth/oauth/token " ) else {
@@ -148,51 +246,26 @@ class CustomerAccountClient {
148
246
completionHandler: { data, _, _ in
149
247
guard let tokenResponse = self . jsonDecoder. decodeOrNil ( CustomerAccountAccessTokenResponse . self, from: data!) else {
150
248
print ( " Couldn't decode access token response " )
249
+ print ( " Response: " , String ( data: data!, encoding: . utf8) !)
151
250
return
152
251
}
153
252
154
253
self . idToken = tokenResponse. idToken
155
254
self . refreshToken = tokenResponse. refreshToken
156
- self . performTokenExchange ( accessToken: tokenResponse. accessToken, callback: callback)
157
- }
158
- )
159
- }
160
-
161
- /// Exchanges access token for a new token that can be used to access Customer Accounts API resources
162
- private func performTokenExchange( accessToken: String , callback: @escaping ( String ? , String ? ) -> Void ) {
163
- guard let url = URL ( string: " \( self . customerAccountsBaseUrl) /auth/oauth/token " ) else {
164
- callback ( nil , " Couldn't build token exchange URL " )
165
- return
166
- }
167
-
168
- let params = [
169
- " grant_type " : CustomerAccountClient . tokenExchangeGrantType,
170
- " client_id " : self . clientId,
171
- " audience " : CustomerAccountClient . customerAccountsAudience,
172
- " subject_token " : accessToken,
173
- " subject_token_type " : CustomerAccountClient . accessTokenSubjectTokenType,
174
- " scopes " : " https://api.customers.com/auth/customer.graphql "
175
- ]
176
-
177
- executeRequest (
178
- url: url,
179
- headers: [
180
- " Content-Type " : CustomerAccountClient . formUrlEncoded
181
- ] ,
182
- body: encodeToFormURLEncoded ( parameters: params) ,
183
- completionHandler: { data, _, _ in
184
- guard let tokenResponse = self . jsonDecoder. decodeOrNil ( CustomerAccountExchangeTokenResponse . self, from: data!) else {
185
- callback ( nil , " Couldn't decode token exchange response " )
186
- return
187
- }
188
- self . accessToken = tokenResponse. accessToken
189
- self . exchangeForStorefrontCustomerAccessToken ( customerAccessToken: tokenResponse. accessToken, callback: callback)
255
+ self . accessToken = tokenResponse. accessToken
256
+ self . accessTokenExpirationManager. addAccessToken ( accessToken: tokenResponse. accessToken, expiresIn: tokenResponse. expiresIn)
257
+ self . exchangeForStorefrontCustomerAccessToken (
258
+ customerAccessToken: tokenResponse. accessToken,
259
+ refreshToken: tokenResponse. refreshToken,
260
+ callback: callback
261
+ )
262
+ self . authenticated = true
190
263
}
191
264
)
192
265
}
193
266
194
267
/// Exchanges a Customer Accounts API access token for a Storefront API customer access token that can be used in Storefront API (and cart mutations)
195
- private func exchangeForStorefrontCustomerAccessToken( customerAccessToken: String , callback: @escaping ( String ? , String ? ) -> Void ) {
268
+ private func exchangeForStorefrontCustomerAccessToken( customerAccessToken: String , refreshToken : String , callback: @escaping ( String ? , String ? ) -> Void ) {
196
269
guard let url = URL ( string: " \( customerAccountsBaseUrl) /account/customer/api/2024-07/graphql " ) else {
197
270
return
198
271
}
@@ -276,25 +349,56 @@ class CustomerAccountClient {
276
349
277
350
private func executeRequest(
278
351
url: URL ,
279
- headers: [ String : String ] ,
280
- body: Data ? ,
352
+ headers: [ String : String ] = [ : ] ,
353
+ method: String = " POST " ,
354
+ body: Data ? = nil ,
355
+ query: [ String : String ] ? = [ : ] ,
281
356
completionHandler: @escaping @Sendable ( Data ? , URLResponse ? , Error ? ) -> Void
282
- ) {
283
- var request = URLRequest ( url: url)
284
- request. httpMethod = " POST "
357
+ ) {
358
+ var components = URLComponents ( url: url, resolvingAgainstBaseURL: false ) !
359
+ if query? . isEmpty != true {
360
+ components. queryItems = [ URLQueryItem] ( )
361
+ }
362
+ query? . forEach { components. queryItems? . append ( URLQueryItem ( name: $0. key, value: $0. value) ) }
363
+
364
+ var request = URLRequest ( url: components. url!)
365
+ request. httpMethod = method
285
366
for (key, value) in headers {
286
367
request. setValue ( value, forHTTPHeaderField: key)
287
368
}
288
369
request. httpBody = body
289
370
290
371
let session = URLSession . shared
291
372
let task = session. dataTask ( with: request) { data, response, error in
292
- completionHandler ( data, response, error)
373
+ print ( " Data: \( String ( decoding: data ?? Data ( ) , as: UTF8 . self) ) , Response: \( String ( describing: response) ) , Error: \( String ( describing: error) ) " )
374
+ completionHandler ( data, response, error)
293
375
}
294
376
task. resume ( )
295
377
}
296
378
}
297
379
380
+ class AccessTokenExpirationManager {
381
+ static let shared = AccessTokenExpirationManager ( )
382
+
383
+ private var accessTokenExpirationMap : [ String : Date ]
384
+
385
+ init ( ) {
386
+ self . accessTokenExpirationMap = [ : ]
387
+ }
388
+
389
+ func addAccessToken( accessToken: String , expiresIn: Int ) {
390
+ accessTokenExpirationMap [ accessToken] = Date ( ) . addingTimeInterval ( Double ( expiresIn) )
391
+ }
392
+
393
+ func isAccessTokenExpired( accessToken: String ) -> Bool {
394
+ return accessTokenExpirationMap [ accessToken] != nil && Date ( ) > accessTokenExpirationMap [ accessToken] !
395
+ }
396
+
397
+ func getExpirationDate( accessToken: String ) -> Date ? {
398
+ return self . accessTokenExpirationMap [ accessToken]
399
+ }
400
+ }
401
+
298
402
struct AuthData {
299
403
let authorizationUrl : URL
300
404
let codeVerifier : String
@@ -306,33 +410,29 @@ struct CustomerAccountAccessTokenResponse: Codable {
306
410
let expiresIn : Int
307
411
let idToken : String
308
412
let refreshToken : String
309
- let scope : String
310
413
let tokenType : String
311
414
312
415
enum CodingKeys : String , CodingKey {
313
416
case accessToken = " access_token "
314
417
case expiresIn = " expires_in "
315
418
case idToken = " id_token "
316
419
case refreshToken = " refresh_token "
317
- case scope = " scope "
318
420
case tokenType = " token_type "
319
421
}
320
422
}
321
423
322
- struct CustomerAccountExchangeTokenResponse : Codable {
323
- let accessToken : String
324
- let expiresIn : Int
325
- let tokenType : String
326
- let scope : String
327
- let issuedTokenType : String
328
-
329
- enum CodingKeys : String , CodingKey {
330
- case accessToken = " access_token "
331
- case expiresIn = " expires_in "
332
- case tokenType = " token_type "
333
- case scope = " scope "
334
- case issuedTokenType = " issued_token_type "
335
- }
424
+ struct CustomerAccountRefreshedTokenResponse : Codable {
425
+ let accessToken : String
426
+ let expiresIn : Int
427
+ let refreshToken : String
428
+ let tokenType : String
429
+
430
+ enum CodingKeys : String , CodingKey {
431
+ case accessToken = " access_token "
432
+ case expiresIn = " expires_in "
433
+ case refreshToken = " refresh_token "
434
+ case tokenType = " token_type "
435
+ }
336
436
}
337
437
338
438
struct MutationRoot : Codable {
0 commit comments