@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.
- package/README.md +206 -39
- package/build/SpotifyAuth.types.d.ts +24 -1
- package/build/SpotifyAuth.types.d.ts.map +1 -1
- package/build/SpotifyAuth.types.js +3 -1
- package/build/SpotifyAuth.types.js.map +1 -1
- package/build/index.d.ts +5 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +22 -6
- package/build/index.js.map +1 -1
- package/ios/SpotifyAuth.podspec +20 -3
- package/ios/SpotifyAuthAuth.swift +386 -33
- package/ios/SpotifyAuthModule.swift +94 -20
- package/package.json +30 -27
- package/plugin/build/index.js +15 -5
- package/plugin/build/ios/withSpotifyURLScheme.js +10 -0
- package/plugin/build/prebuild.d.ts +3 -0
- package/plugin/build/prebuild.js +16 -0
- package/plugin/build/types.d.ts +65 -2
- package/plugin/build/withSpotifyConfig.d.ts +3 -2
- package/plugin/build/withSpotifyConfig.js +61 -6
- package/plugin/src/index.ts +22 -6
- package/plugin/src/ios/withSpotifyURLScheme.ts +13 -0
- package/plugin/src/prebuild.ts +16 -0
- package/plugin/src/types.ts +70 -2
- package/plugin/src/withSpotifyConfig.ts +67 -6
- package/plugin/tsconfig.tsbuildinfo +1 -1
- package/app.plugin.js +0 -1
- package/src/SpotifyAuth.types.ts +0 -54
- package/src/SpotifyAuthModule.ts +0 -6
- package/src/SpotifyAuthView.tsx +0 -14
- package/src/index.tsx +0 -69
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
}()
|
|
141
|
+
}
|
|
29
142
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
return manager
|
|
192
|
+
return SPTSessionManager(configuration: configuration, delegate: self)
|
|
44
193
|
}()
|
|
45
194
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
// Remove any observers or listeners.
|
|
61
|
+
secureLog("Stopped observing events")
|
|
37
62
|
}
|
|
38
63
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
guard let
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
let configuration = SPTConfiguration(clientID: clientId, redirectURL: URL(string: redirectUrl)!)
|
|
75
|
+
let configuration = SPTConfiguration(clientID: config.clientId, redirectURL: url)
|
|
52
76
|
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|