@superfan-app/spotify-auth 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,65 +1,337 @@
1
1
  import ExpoModulesCore
2
2
  import SpotifyiOS
3
+ import KeychainAccess
4
+
5
+ enum SpotifyAuthError: Error {
6
+ case missingConfiguration(String)
7
+ case invalidConfiguration(String)
8
+ case authenticationFailed(String)
9
+ case tokenError(String)
10
+ case sessionError(String)
11
+ case networkError(String)
12
+ case recoverable(String, RetryStrategy)
13
+
14
+ enum RetryStrategy {
15
+ case none
16
+ case retry(attempts: Int, delay: TimeInterval)
17
+ case exponentialBackoff(maxAttempts: Int, initialDelay: TimeInterval)
18
+ }
19
+
20
+ var isRecoverable: Bool {
21
+ switch self {
22
+ case .recoverable:
23
+ return true
24
+ case .networkError:
25
+ return true
26
+ case .tokenError:
27
+ return true
28
+ default:
29
+ return false
30
+ }
31
+ }
32
+
33
+ var localizedDescription: String {
34
+ switch self {
35
+ case .missingConfiguration(let field):
36
+ return "Missing configuration: \(field). Please check your app.json configuration."
37
+ case .invalidConfiguration(let reason):
38
+ return "Invalid configuration: \(reason). Please verify your settings."
39
+ case .authenticationFailed(let reason):
40
+ return "Authentication failed: \(reason). Please try again."
41
+ case .tokenError(let reason):
42
+ return "Token error: \(reason). Please try logging in again."
43
+ case .sessionError(let reason):
44
+ return "Session error: \(reason). Please restart the authentication process."
45
+ case .networkError(let reason):
46
+ return "Network error: \(reason). Please check your internet connection."
47
+ case .recoverable(let message, _):
48
+ return message
49
+ }
50
+ }
51
+
52
+ var retryStrategy: RetryStrategy {
53
+ switch self {
54
+ case .recoverable(_, let strategy):
55
+ return strategy
56
+ case .networkError:
57
+ return .exponentialBackoff(maxAttempts: 3, initialDelay: 1.0)
58
+ case .tokenError:
59
+ return .retry(attempts: 3, delay: 5.0)
60
+ default:
61
+ return .none
62
+ }
63
+ }
64
+ }
3
65
 
4
66
  final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate {
5
67
  weak var module: SpotifyAuthModule?
6
68
 
7
69
  static let shared = SpotifyAuthAuth()
8
70
 
9
- private let clientID: String = Bundle.main.object(forInfoDictionaryKey: "SpotifyClientID") as? String ?? ""
71
+ private var clientID: String {
72
+ get throws {
73
+ guard let value = Bundle.main.object(forInfoDictionaryKey: "SpotifyClientID") as? String,
74
+ !value.isEmpty else {
75
+ throw SpotifyAuthError.missingConfiguration("clientID")
76
+ }
77
+ return value
78
+ }
79
+ }
10
80
 
11
- private let scheme: String = Bundle.main.object(forInfoDictionaryKey: "SpotifyScheme") as? String ?? ""
81
+ private var scheme: String {
82
+ get throws {
83
+ guard let value = Bundle.main.object(forInfoDictionaryKey: "SpotifyScheme") as? String,
84
+ !value.isEmpty else {
85
+ throw SpotifyAuthError.missingConfiguration("scheme")
86
+ }
87
+ return value
88
+ }
89
+ }
12
90
 
13
- private let callback: String = Bundle.main.object(forInfoDictionaryKey: "SpotifyCallback") as? String ?? ""
91
+ private var callback: String {
92
+ get throws {
93
+ guard let value = Bundle.main.object(forInfoDictionaryKey: "SpotifyCallback") as? String,
94
+ !value.isEmpty else {
95
+ throw SpotifyAuthError.missingConfiguration("callback")
96
+ }
97
+ return value
98
+ }
99
+ }
14
100
 
15
- private let tokenRefreshURL: String = Bundle.main.object(forInfoDictionaryKey: "tokenRefreshURL") as? String ?? ""
101
+ private var tokenRefreshURL: String {
102
+ get throws {
103
+ guard let value = Bundle.main.object(forInfoDictionaryKey: "tokenRefreshURL") as? String,
104
+ !value.isEmpty else {
105
+ throw SpotifyAuthError.missingConfiguration("tokenRefreshURL")
106
+ }
107
+ return value
108
+ }
109
+ }
16
110
 
17
- private let tokenSwapURL: String = Bundle.main.object(forInfoDictionaryKey: "tokenSwapURL") as? String ?? ""
111
+ private var tokenSwapURL: String {
112
+ get throws {
113
+ guard let value = Bundle.main.object(forInfoDictionaryKey: "tokenSwapURL") as? String,
114
+ !value.isEmpty else {
115
+ throw SpotifyAuthError.missingConfiguration("tokenSwapURL")
116
+ }
117
+ return value
118
+ }
119
+ }
18
120
 
19
- private lazy var requestedScopes: SPTScope = {
20
- let scopeStrings = Bundle.main.object(forInfoDictionaryKey: "SpotifyScopes") as? [String] ?? []
21
- var combinedScope: SPTScope = []
22
- for scopeString in scopeStrings {
23
- if let scope = stringToScope(scopeString: scopeString) {
24
- combinedScope.insert(scope)
121
+ private var requestedScopes: SPTScope {
122
+ get throws {
123
+ guard let scopeStrings = Bundle.main.object(forInfoDictionaryKey: "SpotifyScopes") as? [String],
124
+ !scopeStrings.isEmpty else {
125
+ throw SpotifyAuthError.missingConfiguration("scopes")
126
+ }
127
+
128
+ var combinedScope: SPTScope = []
129
+ for scopeString in scopeStrings {
130
+ if let scope = stringToScope(scopeString: scopeString) {
131
+ combinedScope.insert(scope)
132
+ }
25
133
  }
134
+
135
+ if combinedScope.isEmpty {
136
+ throw SpotifyAuthError.invalidConfiguration("No valid scopes provided")
137
+ }
138
+
139
+ return combinedScope
26
140
  }
27
- return combinedScope
28
- }()
141
+ }
29
142
 
30
- lazy var configuration = SPTConfiguration(
31
- clientID: clientID,
32
- redirectURL: .init(string: "\(scheme)://\(callback)")!
33
- )
143
+ private func validateAndConfigureURLs(_ config: SPTConfiguration) throws {
144
+ // Validate token swap URL
145
+ guard let tokenSwapURL = URL(string: try self.tokenSwapURL),
146
+ tokenSwapURL.scheme?.lowercased() == "https" else {
147
+ throw SpotifyAuthError.invalidConfiguration("Token swap URL must use HTTPS")
148
+ }
149
+
150
+ // Validate token refresh URL
151
+ guard let tokenRefreshURL = URL(string: try self.tokenRefreshURL),
152
+ tokenRefreshURL.scheme?.lowercased() == "https" else {
153
+ throw SpotifyAuthError.invalidConfiguration("Token refresh URL must use HTTPS")
154
+ }
155
+
156
+ config.tokenSwapURL = tokenSwapURL
157
+ config.tokenRefreshURL = tokenRefreshURL
158
+
159
+ // Configure session for secure communication
160
+ let session = URLSession(configuration: .ephemeral)
161
+ session.configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
162
+ session.configuration.httpAdditionalHeaders = [
163
+ "X-Client-ID": try self.clientID // Add secure client identification
164
+ ]
165
+ }
166
+
167
+ lazy var configuration: SPTConfiguration? = {
168
+ do {
169
+ let clientID = try self.clientID
170
+ let scheme = try self.scheme
171
+ let callback = try self.callback
172
+
173
+ guard let redirectUrl = URL(string: "\(scheme)://\(callback)") else {
174
+ throw SpotifyAuthError.invalidConfiguration("Invalid redirect URL formation")
175
+ }
176
+
177
+ let config = SPTConfiguration(clientID: clientID, redirectURL: redirectUrl)
178
+ try validateAndConfigureURLs(config)
179
+
180
+ return config
181
+ } catch {
182
+ module?.onAuthorizationError(error.localizedDescription)
183
+ return nil
184
+ }
185
+ }()
34
186
 
35
- lazy var sessionManager: SPTSessionManager = {
36
- if let tokenSwapURL = URL(string: self.tokenSwapURL),
37
- let tokenRefreshURL = URL(string: self.tokenRefreshURL)
38
- {
39
- self.configuration.tokenSwapURL = tokenSwapURL
40
- self.configuration.tokenRefreshURL = tokenRefreshURL
187
+ lazy var sessionManager: SPTSessionManager? = {
188
+ guard let configuration = self.configuration else {
189
+ module?.onAuthorizationError("Failed to create configuration")
190
+ return nil
41
191
  }
42
- let manager = SPTSessionManager(configuration: self.configuration, delegate: self)
43
- return manager
192
+ return SPTSessionManager(configuration: configuration, delegate: self)
44
193
  }()
45
194
 
46
- public func initAuth() {
47
- sessionManager.initiateSession(with: requestedScopes, options: .default)
195
+ private var currentSession: SPTSession? {
196
+ didSet {
197
+ cleanupPreviousSession()
198
+ if let session = currentSession {
199
+ securelyStoreToken(session)
200
+ scheduleTokenRefresh(session)
201
+ }
202
+ }
203
+ }
204
+
205
+ private var isAuthenticating: Bool = false {
206
+ didSet {
207
+ if !isAuthenticating && currentSession == nil {
208
+ module?.onAuthorizationError("Authentication process ended without session")
209
+ }
210
+ }
48
211
  }
49
212
 
50
- public func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
51
- print("success", session)
213
+ private var refreshTimer: Timer?
214
+
215
+ private func scheduleTokenRefresh(_ session: SPTSession) {
216
+ refreshTimer?.invalidate()
217
+
218
+ // Schedule refresh 5 minutes before expiration
219
+ let refreshInterval = TimeInterval(session.expirationDate.timeIntervalSinceNow - 300)
220
+ if refreshInterval > 0 {
221
+ refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: false) { [weak self] _ in
222
+ self?.refreshToken()
223
+ }
224
+ } else {
225
+ refreshToken()
226
+ }
227
+ }
228
+
229
+ private func getKeychainKey() throws -> String {
230
+ let clientID = try self.clientID
231
+ let scheme = try self.scheme
232
+ return "expo.modules.spotifyauth.\(scheme).\(clientID).refresh_token"
233
+ }
234
+
235
+ private func securelyStoreToken(_ session: SPTSession) {
236
+ // Only pass token to JS, store sensitive data securely
52
237
  module?.onAccessTokenObtained(session.accessToken)
238
+
239
+ // Store refresh token in keychain if available
240
+ if let refreshToken = session.refreshToken {
241
+ do {
242
+ let keychainKey = try getKeychainKey()
243
+ try KeychainAccess.store(
244
+ key: keychainKey,
245
+ data: refreshToken.data(using: .utf8)!,
246
+ accessibility: .afterFirstUnlock
247
+ )
248
+ } catch {
249
+ print("Failed to store refresh token securely")
250
+ }
251
+ }
252
+ }
253
+
254
+ private func cleanupPreviousSession() {
255
+ // Clear any sensitive data from previous session
256
+ refreshTimer?.invalidate()
257
+
258
+ do {
259
+ let keychainKey = try getKeychainKey()
260
+ try KeychainAccess.delete(key: keychainKey)
261
+ } catch {
262
+ print("Failed to clear previous refresh token")
263
+ }
264
+
265
+ // Clear in-memory session data
266
+ currentSession = nil
267
+ }
268
+
269
+ private func refreshToken(retryCount: Int = 0) {
270
+ guard let sessionManager = self.sessionManager else {
271
+ handleError(SpotifyAuthError.sessionError("Session manager not available"), context: "token_refresh")
272
+ return
273
+ }
274
+
275
+ do {
276
+ try sessionManager.renewSession()
277
+ } catch {
278
+ handleError(error, context: "token_refresh")
279
+ }
280
+ }
281
+
282
+ public func initAuth(showDialog: Bool = false) {
283
+ do {
284
+ guard let sessionManager = self.sessionManager else {
285
+ throw SpotifyAuthError.sessionError("Session manager not initialized")
286
+ }
287
+ let scopes = try self.requestedScopes
288
+ isAuthenticating = true
289
+
290
+ let options: SPTConfiguration.AuthorizationOptions = showDialog ? .clientOnly : .default
291
+ sessionManager.initiateSession(with: scopes, options: options)
292
+ } catch {
293
+ isAuthenticating = false
294
+ handleError(error, context: "authentication")
295
+ }
296
+ }
297
+
298
+ private func retryAuthentication() {
299
+ guard !isAuthenticating else { return }
300
+
301
+ do {
302
+ guard let sessionManager = self.sessionManager else {
303
+ throw SpotifyAuthError.sessionError("Session manager not initialized")
304
+ }
305
+ let scopes = try self.requestedScopes
306
+ isAuthenticating = true
307
+ sessionManager.initiateSession(with: scopes, options: .default)
308
+ } catch {
309
+ isAuthenticating = false
310
+ handleError(error, context: "authentication_retry")
311
+ }
312
+ }
313
+
314
+ public func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
315
+ secureLog("Authentication successful")
316
+ isAuthenticating = false
317
+ currentSession = session
53
318
  }
54
319
 
55
320
  public func sessionManager(manager _: SPTSessionManager, didFailWith error: Error) {
56
- print("fail", error)
57
- module?.onAuthorizationError(error.localizedDescription)
321
+ secureLog("Authentication failed")
322
+ isAuthenticating = false
323
+ currentSession = nil
324
+ handleError(error, context: "authentication")
58
325
  }
59
326
 
60
327
  public func sessionManager(manager _: SPTSessionManager, didRenew session: SPTSession) {
61
- print("renewed", session)
62
- module?.onAccessTokenObtained(session.accessToken)
328
+ secureLog("Token renewed successfully")
329
+ currentSession = session
330
+ }
331
+
332
+ public func clearSession() {
333
+ currentSession = nil
334
+ module?.onSignOut()
63
335
  }
64
336
 
65
337
  private func stringToScope(scopeString: String) -> SPTScope? {
@@ -106,4 +378,85 @@ final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate {
106
378
  return nil
107
379
  }
108
380
  }
381
+
382
+ private func handleError(_ error: Error, context: String) {
383
+ let spotifyError: SpotifyAuthError
384
+
385
+ if let error = error as? SpotifyAuthError {
386
+ spotifyError = error
387
+ } else if let sptError = error as? SPTError {
388
+ switch sptError {
389
+ case .configurationError:
390
+ spotifyError = .invalidConfiguration("Invalid Spotify configuration")
391
+ case .authenticationError:
392
+ spotifyError = .authenticationFailed("Please try authenticating again")
393
+ case .loggedOut:
394
+ spotifyError = .sessionError("User logged out")
395
+ default:
396
+ spotifyError = .authenticationFailed(sptError.localizedDescription)
397
+ }
398
+ } else {
399
+ spotifyError = .authenticationFailed(error.localizedDescription)
400
+ }
401
+
402
+ secureLog("Error in \(context): \(spotifyError.localizedDescription)")
403
+
404
+ switch spotifyError.retryStrategy {
405
+ case .none:
406
+ module?.onAuthorizationError(spotifyError.localizedDescription)
407
+ cleanupPreviousSession()
408
+
409
+ case .retry(let attempts, let delay):
410
+ handleRetry(error: spotifyError, context: context, remainingAttempts: attempts, delay: delay)
411
+
412
+ case .exponentialBackoff(let maxAttempts, let initialDelay):
413
+ handleExponentialBackoff(error: spotifyError, context: context, remainingAttempts: maxAttempts, currentDelay: initialDelay)
414
+ }
415
+ }
416
+
417
+ private func handleRetry(error: SpotifyAuthError, context: String, remainingAttempts: Int, delay: TimeInterval) {
418
+ guard remainingAttempts > 0 else {
419
+ module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
420
+ cleanupPreviousSession()
421
+ return
422
+ }
423
+
424
+ secureLog("Retrying \(context) in \(delay) seconds. Attempts remaining: \(remainingAttempts)")
425
+
426
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
427
+ switch context {
428
+ case "token_refresh":
429
+ self?.refreshToken(retryCount: 3 - remainingAttempts)
430
+ case "authentication":
431
+ self?.retryAuthentication()
432
+ default:
433
+ break
434
+ }
435
+ }
436
+ }
437
+
438
+ private func handleExponentialBackoff(error: SpotifyAuthError, context: String, remainingAttempts: Int, currentDelay: TimeInterval) {
439
+ guard remainingAttempts > 0 else {
440
+ module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
441
+ cleanupPreviousSession()
442
+ return
443
+ }
444
+
445
+ secureLog("Retrying \(context) in \(currentDelay) seconds. Attempts remaining: \(remainingAttempts)")
446
+
447
+ DispatchQueue.main.asyncAfter(deadline: .now() + currentDelay) { [weak self] in
448
+ switch context {
449
+ case "token_refresh":
450
+ self?.refreshToken(retryCount: 3 - remainingAttempts)
451
+ case "authentication":
452
+ self?.retryAuthentication()
453
+ default:
454
+ break
455
+ }
456
+ }
457
+ }
458
+
459
+ deinit {
460
+ cleanupPreviousSession()
461
+ }
109
462
  }
@@ -3,6 +3,33 @@ import SpotifyiOS
3
3
 
4
4
  let SPOTIFY_AUTHORIZATION_EVENT_NAME = "onSpotifyAuth"
5
5
 
6
+ #if DEBUG
7
+ func secureLog(_ message: String, sensitive: Bool = false) {
8
+ if sensitive {
9
+ print("[SpotifyAuth] ********")
10
+ } else {
11
+ print("[SpotifyAuth] \(message)")
12
+ }
13
+ }
14
+ #else
15
+ func secureLog(_ message: String, sensitive: Bool = false) {
16
+ if !sensitive {
17
+ print("[SpotifyAuth] \(message)")
18
+ }
19
+ }
20
+ #endif
21
+
22
+ struct AuthorizeConfig: Record {
23
+ @Field
24
+ var clientId: String
25
+
26
+ @Field
27
+ var redirectUrl: String
28
+
29
+ @Field
30
+ var showDialog: Bool = false
31
+ }
32
+
6
33
  public class SpotifyAuthModule: Module {
7
34
  let spotifyAuth = SpotifyAuthAuth.shared
8
35
 
@@ -14,6 +41,7 @@ public class SpotifyAuthModule: Module {
14
41
 
15
42
  OnCreate {
16
43
  SpotifyAuthAuth.shared.module = self
44
+ secureLog("Module initialized")
17
45
  }
18
46
 
19
47
  Constants([
@@ -25,32 +53,35 @@ public class SpotifyAuthModule: Module {
25
53
 
26
54
  // This will be called when JS starts observing the event.
27
55
  OnStartObserving {
28
- print("OnStartObserving")
29
- // Add any observers or listeners, if required.
30
- // In this case, you might not need anything here.
56
+ secureLog("Started observing events")
31
57
  }
32
58
 
33
59
  // This will be called when JS stops observing the event.
34
60
  OnStopObserving {
35
- print("OnStopObserving")
36
- // Remove any observers or listeners.
61
+ secureLog("Stopped observing events")
37
62
  }
38
63
 
39
- @objc(authorize:resolver:rejecter:)
40
- func authorize(_ config: NSDictionary,
41
- resolver resolve: @escaping RCTPromiseResolveBlock,
42
- rejecter reject: @escaping RCTPromiseRejectBlock) -> Void {
43
- guard let clientId = config["clientId"] as? String,
44
- let redirectUrl = config["redirectUrl"] as? String else {
45
- reject("invalid_config", "Missing clientId or redirectUrl", nil)
64
+ AsyncFunction("authorize") { (config: AuthorizeConfig, promise: Promise) in
65
+ secureLog("Authorization requested")
66
+
67
+ // Sanitize and validate redirect URL
68
+ guard let url = URL(string: config.redirectUrl),
69
+ url.scheme != nil,
70
+ url.host != nil else {
71
+ promise.reject(SpotifyAuthError.invalidConfiguration("Invalid redirect URL format"))
46
72
  return
47
73
  }
48
74
 
49
- let showDialog = config["showDialog"] as? Bool ?? false
50
-
51
- let configuration = SPTConfiguration(clientID: clientId, redirectURL: URL(string: redirectUrl)!)
75
+ let configuration = SPTConfiguration(clientID: config.clientId, redirectURL: url)
52
76
 
53
- spotifyAuth.initAuth(configuration)
77
+ do {
78
+ try spotifyAuth.initAuth(showDialog: config.showDialog)
79
+ promise.resolve()
80
+ } catch {
81
+ // Sanitize error message
82
+ let sanitizedError = sanitizeErrorMessage(error.localizedDescription)
83
+ promise.reject(SpotifyAuthError.authenticationFailed(sanitizedError))
84
+ }
54
85
  }
55
86
 
56
87
  // Enables the module to be used as a native view. Definition components that are accepted as part of the
@@ -58,23 +89,66 @@ public class SpotifyAuthModule: Module {
58
89
  View(SpotifyAuthView.self) {
59
90
  // Defines a setter for the `name` prop.
60
91
  Prop("name") { (_: SpotifyAuthView, prop: String) in
61
- print(prop)
92
+ secureLog("View prop updated: \(prop)")
93
+ }
94
+ }
95
+ }
96
+
97
+ private func sanitizeErrorMessage(_ message: String) -> String {
98
+ // Remove any potential sensitive data from error messages
99
+ let sensitivePatterns = [
100
+ "(?i)client[_-]?id",
101
+ "(?i)token",
102
+ "(?i)secret",
103
+ "(?i)key",
104
+ "(?i)auth",
105
+ "(?i)password"
106
+ ]
107
+
108
+ var sanitized = message
109
+ for pattern in sensitivePatterns {
110
+ if let regex = try? NSRegularExpression(pattern: pattern) {
111
+ sanitized = regex.stringByReplacingMatches(
112
+ in: sanitized,
113
+ range: NSRange(sanitized.startIndex..., in: sanitized),
114
+ withTemplate: "[REDACTED]"
115
+ )
62
116
  }
63
117
  }
118
+ return sanitized
64
119
  }
65
120
 
66
121
  @objc
67
122
  public func onAccessTokenObtained(_ token: String) {
68
- sendEvent(SPOTIFY_AUTHORIZATION_EVENT_NAME, ["success": true, "token": token])
123
+ secureLog("Access token obtained", sensitive: true)
124
+ let eventData: [String: Any] = [
125
+ "success": true,
126
+ "token": token,
127
+ "error": nil
128
+ ]
129
+ sendEvent(SPOTIFY_AUTHORIZATION_EVENT_NAME, eventData)
69
130
  }
70
131
 
71
132
  @objc
72
133
  public func onSignOut() {
73
- sendEvent(SPOTIFY_AUTHORIZATION_EVENT_NAME, ["success": true, "token": nil])
134
+ secureLog("User signed out")
135
+ let eventData: [String: Any] = [
136
+ "success": true,
137
+ "token": nil,
138
+ "error": nil
139
+ ]
140
+ sendEvent(SPOTIFY_AUTHORIZATION_EVENT_NAME, eventData)
74
141
  }
75
142
 
76
143
  @objc
77
144
  public func onAuthorizationError(_ errorDescription: String) {
78
- sendEvent(SPOTIFY_AUTHORIZATION_EVENT_NAME, ["success": false, "error": errorDescription, "token": nil])
145
+ let sanitizedError = sanitizeErrorMessage(errorDescription)
146
+ secureLog("Authorization error: \(sanitizedError)")
147
+ let eventData: [String: Any] = [
148
+ "success": false,
149
+ "token": nil,
150
+ "error": sanitizedError
151
+ ]
152
+ sendEvent(SPOTIFY_AUTHORIZATION_EVENT_NAME, eventData)
79
153
  }
80
154
  }