@superfan-app/spotify-auth 0.1.33 → 0.1.35
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/ios/SpotifyAuthAuth.swift +650 -559
- package/ios/SpotifyAuthModule.swift +19 -5
- package/package.json +1 -1
|
@@ -2,593 +2,684 @@ import ExpoModulesCore
|
|
|
2
2
|
import SpotifyiOS
|
|
3
3
|
import KeychainAccess
|
|
4
4
|
|
|
5
|
+
/// A lightweight session model to hold token information.
|
|
6
|
+
struct SpotifySessionData {
|
|
7
|
+
let accessToken: String
|
|
8
|
+
let refreshToken: String
|
|
9
|
+
let expirationDate: Date
|
|
10
|
+
|
|
11
|
+
var isExpired: Bool {
|
|
12
|
+
return Date() >= expirationDate
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
init(accessToken: String, refreshToken: String, expirationDate: Date) {
|
|
16
|
+
self.accessToken = accessToken
|
|
17
|
+
self.refreshToken = refreshToken
|
|
18
|
+
self.expirationDate = expirationDate
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Initialize from an SPTSession (from app‑switch flow)
|
|
22
|
+
init?(session: SPTSession) {
|
|
23
|
+
// We assume that SPTSession has valid properties.
|
|
24
|
+
self.accessToken = session.accessToken
|
|
25
|
+
self.refreshToken = session.refreshToken
|
|
26
|
+
self.expirationDate = session.expirationDate
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
5
30
|
enum SpotifyAuthError: Error {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
31
|
+
case missingConfiguration(String)
|
|
32
|
+
case invalidConfiguration(String)
|
|
33
|
+
case authenticationFailed(String)
|
|
34
|
+
case tokenError(String)
|
|
35
|
+
case sessionError(String)
|
|
36
|
+
case networkError(String)
|
|
37
|
+
case recoverable(String, RetryStrategy)
|
|
38
|
+
|
|
39
|
+
enum RetryStrategy {
|
|
40
|
+
case none
|
|
41
|
+
case retry(attempts: Int, delay: TimeInterval)
|
|
42
|
+
case exponentialBackoff(maxAttempts: Int, initialDelay: TimeInterval)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
var isRecoverable: Bool {
|
|
46
|
+
switch self {
|
|
47
|
+
case .recoverable:
|
|
48
|
+
return true
|
|
49
|
+
case .networkError:
|
|
50
|
+
return true
|
|
51
|
+
case .tokenError:
|
|
52
|
+
return true
|
|
53
|
+
default:
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
var localizedDescription: String {
|
|
59
|
+
switch self {
|
|
60
|
+
case .missingConfiguration(let field):
|
|
61
|
+
return "Missing configuration: \(field). Please check your app.json configuration."
|
|
62
|
+
case .invalidConfiguration(let reason):
|
|
63
|
+
return "Invalid configuration: \(reason). Please verify your settings."
|
|
64
|
+
case .authenticationFailed(let reason):
|
|
65
|
+
return "Authentication failed: \(reason). Please try again."
|
|
66
|
+
case .tokenError(let reason):
|
|
67
|
+
return "Token error: \(reason). Please try logging in again."
|
|
68
|
+
case .sessionError(let reason):
|
|
69
|
+
return "Session error: \(reason). Please restart the authentication process."
|
|
70
|
+
case .networkError(let reason):
|
|
71
|
+
return "Network error: \(reason). Please check your internet connection."
|
|
72
|
+
case .recoverable(let message, _):
|
|
73
|
+
return message
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var retryStrategy: RetryStrategy {
|
|
78
|
+
switch self {
|
|
79
|
+
case .recoverable(_, let strategy):
|
|
80
|
+
return strategy
|
|
81
|
+
case .networkError:
|
|
82
|
+
return .exponentialBackoff(maxAttempts: 3, initialDelay: 1.0)
|
|
83
|
+
case .tokenError:
|
|
84
|
+
return .retry(attempts: 3, delay: 5.0)
|
|
85
|
+
default:
|
|
86
|
+
return .none
|
|
87
|
+
}
|
|
88
|
+
}
|
|
64
89
|
}
|
|
65
90
|
|
|
66
91
|
final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthViewDelegate {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
/// A weak reference to our module’s JS interface.
|
|
93
|
+
weak var module: SpotifyAuthModule?
|
|
94
|
+
|
|
95
|
+
/// For web‑auth we present our own OAuth view.
|
|
96
|
+
private var webAuthView: SpotifyOAuthView?
|
|
97
|
+
private var isUsingWebAuth = false
|
|
98
|
+
|
|
99
|
+
/// Stores the active configuration from JS.
|
|
100
|
+
private var currentConfig: AuthorizeConfig?
|
|
101
|
+
|
|
102
|
+
/// Our own session model.
|
|
103
|
+
private var currentSession: SpotifySessionData? {
|
|
104
|
+
didSet {
|
|
105
|
+
cleanupPreviousSession()
|
|
106
|
+
if let session = currentSession {
|
|
107
|
+
securelyStoreToken(session)
|
|
108
|
+
scheduleTokenRefresh(session)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// If authentication is in progress.
|
|
114
|
+
private var isAuthenticating: Bool = false {
|
|
115
|
+
didSet {
|
|
116
|
+
if !isAuthenticating && currentSession == nil {
|
|
117
|
+
module?.onAuthorizationError("Authentication process ended without session")
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private var refreshTimer: Timer?
|
|
123
|
+
|
|
124
|
+
static let shared = SpotifyAuthAuth()
|
|
125
|
+
|
|
126
|
+
// MARK: - Configuration Accessors
|
|
127
|
+
|
|
128
|
+
private var clientID: String {
|
|
129
|
+
get throws {
|
|
130
|
+
guard let config = currentConfig else {
|
|
131
|
+
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
132
|
+
}
|
|
133
|
+
guard !config.clientId.isEmpty else {
|
|
134
|
+
throw SpotifyAuthError.missingConfiguration("clientId")
|
|
135
|
+
}
|
|
136
|
+
return config.clientId
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private var redirectURL: URL {
|
|
141
|
+
get throws {
|
|
142
|
+
guard let config = currentConfig else {
|
|
143
|
+
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
144
|
+
}
|
|
145
|
+
guard let url = URL(string: config.redirectUrl),
|
|
146
|
+
url.scheme != nil,
|
|
147
|
+
url.host != nil else {
|
|
148
|
+
throw SpotifyAuthError.invalidConfiguration("Invalid redirect URL format")
|
|
149
|
+
}
|
|
150
|
+
return url
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private var tokenRefreshURL: String {
|
|
155
|
+
get throws {
|
|
156
|
+
guard let config = currentConfig else {
|
|
157
|
+
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
158
|
+
}
|
|
159
|
+
guard !config.tokenRefreshURL.isEmpty else {
|
|
160
|
+
throw SpotifyAuthError.missingConfiguration("tokenRefreshURL")
|
|
161
|
+
}
|
|
162
|
+
return config.tokenRefreshURL
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private var tokenSwapURL: String {
|
|
167
|
+
get throws {
|
|
168
|
+
guard let config = currentConfig else {
|
|
169
|
+
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
170
|
+
}
|
|
171
|
+
guard !config.tokenSwapURL.isEmpty else {
|
|
172
|
+
throw SpotifyAuthError.missingConfiguration("tokenSwapURL")
|
|
173
|
+
}
|
|
174
|
+
return config.tokenSwapURL
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private var requestedScopes: SPTScope {
|
|
179
|
+
get throws {
|
|
180
|
+
guard let config = currentConfig else {
|
|
181
|
+
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
182
|
+
}
|
|
183
|
+
guard !config.scopes.isEmpty else {
|
|
184
|
+
throw SpotifyAuthError.missingConfiguration("scopes")
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
var combinedScope: SPTScope = []
|
|
188
|
+
for scopeString in config.scopes {
|
|
189
|
+
if let scope = stringToScope(scopeString: scopeString) {
|
|
190
|
+
combinedScope.insert(scope)
|
|
83
191
|
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if combinedScope.isEmpty {
|
|
195
|
+
throw SpotifyAuthError.invalidConfiguration("No valid scopes provided")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return combinedScope
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Validate and set up secure URLs on the SPTConfiguration.
|
|
203
|
+
private func validateAndConfigureURLs(_ config: SPTConfiguration) throws {
|
|
204
|
+
// Validate token swap URL
|
|
205
|
+
guard let tokenSwapURL = URL(string: try self.tokenSwapURL),
|
|
206
|
+
tokenSwapURL.scheme?.lowercased() == "https" else {
|
|
207
|
+
throw SpotifyAuthError.invalidConfiguration("Token swap URL must use HTTPS")
|
|
84
208
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
guard let url = URL(string: config.redirectUrl),
|
|
92
|
-
url.scheme != nil,
|
|
93
|
-
url.host != nil else {
|
|
94
|
-
throw SpotifyAuthError.invalidConfiguration("Invalid redirect URL format")
|
|
95
|
-
}
|
|
96
|
-
return url
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private var tokenRefreshURL: String {
|
|
101
|
-
get throws {
|
|
102
|
-
guard let config = currentConfig else {
|
|
103
|
-
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
104
|
-
}
|
|
105
|
-
guard !config.tokenRefreshURL.isEmpty else {
|
|
106
|
-
throw SpotifyAuthError.missingConfiguration("tokenRefreshURL")
|
|
107
|
-
}
|
|
108
|
-
return config.tokenRefreshURL
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
private var tokenSwapURL: String {
|
|
113
|
-
get throws {
|
|
114
|
-
guard let config = currentConfig else {
|
|
115
|
-
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
116
|
-
}
|
|
117
|
-
guard !config.tokenSwapURL.isEmpty else {
|
|
118
|
-
throw SpotifyAuthError.missingConfiguration("tokenSwapURL")
|
|
119
|
-
}
|
|
120
|
-
return config.tokenSwapURL
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
private var requestedScopes: SPTScope {
|
|
125
|
-
get throws {
|
|
126
|
-
guard let config = currentConfig else {
|
|
127
|
-
throw SpotifyAuthError.missingConfiguration("No active configuration")
|
|
128
|
-
}
|
|
129
|
-
guard !config.scopes.isEmpty else {
|
|
130
|
-
throw SpotifyAuthError.missingConfiguration("scopes")
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
var combinedScope: SPTScope = []
|
|
134
|
-
for scopeString in config.scopes {
|
|
135
|
-
if let scope = stringToScope(scopeString: scopeString) {
|
|
136
|
-
combinedScope.insert(scope)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if combinedScope.isEmpty {
|
|
141
|
-
throw SpotifyAuthError.invalidConfiguration("No valid scopes provided")
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return combinedScope
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
private func validateAndConfigureURLs(_ config: SPTConfiguration) throws {
|
|
149
|
-
// Validate token swap URL
|
|
150
|
-
guard let tokenSwapURL = URL(string: try self.tokenSwapURL),
|
|
151
|
-
tokenSwapURL.scheme?.lowercased() == "https" else {
|
|
152
|
-
throw SpotifyAuthError.invalidConfiguration("Token swap URL must use HTTPS")
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Validate token refresh URL
|
|
156
|
-
guard let tokenRefreshURL = URL(string: try self.tokenRefreshURL),
|
|
157
|
-
tokenRefreshURL.scheme?.lowercased() == "https" else {
|
|
158
|
-
throw SpotifyAuthError.invalidConfiguration("Token refresh URL must use HTTPS")
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
config.tokenSwapURL = tokenSwapURL
|
|
162
|
-
config.tokenRefreshURL = tokenRefreshURL
|
|
163
|
-
|
|
164
|
-
// Configure session for secure communication
|
|
165
|
-
let session = URLSession(configuration: .ephemeral)
|
|
166
|
-
session.configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
|
|
167
|
-
session.configuration.httpAdditionalHeaders = [
|
|
168
|
-
"X-Client-ID": try self.clientID // Add secure client identification
|
|
169
|
-
]
|
|
209
|
+
|
|
210
|
+
// Validate token refresh URL
|
|
211
|
+
guard let tokenRefreshURL = URL(string: try self.tokenRefreshURL),
|
|
212
|
+
tokenRefreshURL.scheme?.lowercased() == "https" else {
|
|
213
|
+
throw SpotifyAuthError.invalidConfiguration("Token refresh URL must use HTTPS")
|
|
170
214
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
|
|
216
|
+
config.tokenSwapURL = tokenSwapURL
|
|
217
|
+
config.tokenRefreshURL = tokenRefreshURL
|
|
218
|
+
|
|
219
|
+
// Configure secure communication headers.
|
|
220
|
+
let session = URLSession(configuration: .ephemeral)
|
|
221
|
+
session.configuration.tlsMinimumSupportedProtocolVersion = .TLSv12
|
|
222
|
+
session.configuration.httpAdditionalHeaders = [
|
|
223
|
+
"X-Client-ID": try self.clientID
|
|
224
|
+
]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// MARK: - SPTConfiguration and Session Manager (for app‑switch auth)
|
|
228
|
+
|
|
229
|
+
lazy var configuration: SPTConfiguration? = {
|
|
230
|
+
do {
|
|
231
|
+
let clientID = try self.clientID
|
|
232
|
+
let redirectUrl = try self.redirectURL
|
|
233
|
+
let config = SPTConfiguration(clientID: clientID, redirectURL: redirectUrl)
|
|
234
|
+
try validateAndConfigureURLs(config)
|
|
235
|
+
return config
|
|
236
|
+
} catch {
|
|
237
|
+
module?.onAuthorizationError(error.localizedDescription)
|
|
238
|
+
return nil
|
|
239
|
+
}
|
|
240
|
+
}()
|
|
241
|
+
|
|
242
|
+
lazy var sessionManager: SPTSessionManager? = {
|
|
243
|
+
guard let configuration = self.configuration else {
|
|
244
|
+
module?.onAuthorizationError("Failed to create configuration")
|
|
245
|
+
return nil
|
|
246
|
+
}
|
|
247
|
+
return SPTSessionManager(configuration: configuration, delegate: self)
|
|
248
|
+
}()
|
|
249
|
+
|
|
250
|
+
// MARK: - Session Refresh and Secure Storage
|
|
251
|
+
|
|
252
|
+
private func scheduleTokenRefresh(_ session: SpotifySessionData) {
|
|
253
|
+
refreshTimer?.invalidate()
|
|
254
|
+
|
|
255
|
+
// Schedule refresh 5 minutes before expiration.
|
|
256
|
+
let refreshInterval = session.expirationDate.timeIntervalSinceNow - 300
|
|
257
|
+
if refreshInterval > 0 {
|
|
258
|
+
refreshTimer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: false) { [weak self] _ in
|
|
259
|
+
self?.refreshToken()
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
refreshToken()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func getKeychainKey() throws -> String {
|
|
267
|
+
let clientID = try self.clientID
|
|
268
|
+
let redirectUrl = try self.redirectURL
|
|
269
|
+
return "expo.modules.spotifyauth.\(redirectUrl.scheme ?? "unknown").\(clientID).refresh_token"
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func securelyStoreToken(_ session: SpotifySessionData) {
|
|
273
|
+
// Pass token back to JS.
|
|
274
|
+
module?.onAccessTokenObtained(session.accessToken)
|
|
275
|
+
|
|
276
|
+
let refreshToken = session.refreshToken
|
|
277
|
+
if !refreshToken.isEmpty {
|
|
278
|
+
do {
|
|
279
|
+
let keychainKey = try getKeychainKey()
|
|
280
|
+
let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
|
|
281
|
+
.accessibility(.afterFirstUnlock)
|
|
282
|
+
try keychain.set(refreshToken, key: keychainKey)
|
|
283
|
+
} catch {
|
|
284
|
+
print("Failed to store refresh token securely: \(error)")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private func cleanupPreviousSession() {
|
|
290
|
+
refreshTimer?.invalidate()
|
|
291
|
+
|
|
292
|
+
do {
|
|
293
|
+
let keychainKey = try getKeychainKey()
|
|
294
|
+
let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
|
|
295
|
+
try keychain.remove(keychainKey)
|
|
296
|
+
} catch {
|
|
297
|
+
print("Failed to clear previous refresh token: \(error)")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Refresh token either via SPTSessionManager (app‑switch) or manually (web‑auth).
|
|
302
|
+
private func refreshToken(retryCount: Int = 0) {
|
|
303
|
+
if isUsingWebAuth {
|
|
304
|
+
manualRefreshToken(retryCount: retryCount)
|
|
305
|
+
} else {
|
|
306
|
+
guard let sessionManager = self.sessionManager else {
|
|
307
|
+
handleError(SpotifyAuthError.sessionError("Session manager not available"), context: "token_refresh")
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
do {
|
|
311
|
+
try sessionManager.renewSession()
|
|
312
|
+
} catch {
|
|
313
|
+
handleError(error, context: "token_refresh")
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// Manual refresh (for web‑auth) that calls the token refresh endpoint.
|
|
319
|
+
private func manualRefreshToken(retryCount: Int = 0) {
|
|
320
|
+
guard let currentSession = self.currentSession else {
|
|
321
|
+
handleError(SpotifyAuthError.sessionError("No session available"), context: "token_refresh")
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
guard let refreshURLString = try? self.tokenRefreshURL,
|
|
325
|
+
let url = URL(string: refreshURLString) else {
|
|
326
|
+
handleError(SpotifyAuthError.invalidConfiguration("Invalid token refresh URL"), context: "token_refresh")
|
|
327
|
+
return
|
|
203
328
|
}
|
|
204
329
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
330
|
+
var request = URLRequest(url: url)
|
|
331
|
+
request.httpMethod = "POST"
|
|
332
|
+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
333
|
+
|
|
334
|
+
let params: [String: String]
|
|
335
|
+
do {
|
|
336
|
+
params = [
|
|
337
|
+
"grant_type": "refresh_token",
|
|
338
|
+
"refresh_token": currentSession.refreshToken,
|
|
339
|
+
"client_id": try self.clientID
|
|
340
|
+
]
|
|
341
|
+
} catch {
|
|
342
|
+
handleError(error, context: "token_refresh")
|
|
343
|
+
return
|
|
211
344
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
345
|
+
|
|
346
|
+
let bodyString = params.map { "\($0)=\($1)" }.joined(separator: "&")
|
|
347
|
+
request.httpBody = bodyString.data(using: .utf8)
|
|
348
|
+
|
|
349
|
+
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
350
|
+
if let error = error {
|
|
351
|
+
self?.handleError(error, context: "token_refresh")
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
guard let data = data,
|
|
355
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
356
|
+
let accessToken = json["access_token"] as? String,
|
|
357
|
+
let expiresIn = json["expires_in"] as? TimeInterval else {
|
|
358
|
+
self?.handleError(SpotifyAuthError.tokenError("Invalid token refresh response"), context: "token_refresh")
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let newRefreshToken = (json["refresh_token"] as? String) ?? currentSession.refreshToken
|
|
363
|
+
let expirationDate = Date(timeIntervalSinceNow: expiresIn)
|
|
364
|
+
let newSession = SpotifySessionData(accessToken: accessToken, refreshToken: newRefreshToken, expirationDate: expirationDate)
|
|
365
|
+
DispatchQueue.main.async {
|
|
366
|
+
self?.currentSession = newSession
|
|
367
|
+
self?.module?.onAccessTokenObtained(accessToken)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
task.resume()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// MARK: - Authentication Flow
|
|
374
|
+
|
|
375
|
+
public func initAuth(config: AuthorizeConfig) {
|
|
376
|
+
do {
|
|
377
|
+
// Save configuration.
|
|
378
|
+
self.currentConfig = config
|
|
379
|
+
|
|
380
|
+
guard let sessionManager = self.sessionManager else {
|
|
381
|
+
throw SpotifyAuthError.sessionError("Session manager not initialized")
|
|
382
|
+
}
|
|
383
|
+
let scopes = try self.requestedScopes
|
|
384
|
+
isAuthenticating = true
|
|
385
|
+
|
|
386
|
+
if sessionManager.isSpotifyAppInstalled {
|
|
387
|
+
// Use the native app‑switch flow.
|
|
388
|
+
if config.showDialog {
|
|
389
|
+
sessionManager.alwaysShowAuthorizationDialog = true
|
|
226
390
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
391
|
+
sessionManager.initiateSession(with: scopes, options: .default, campaign: config.campaign)
|
|
392
|
+
} else {
|
|
393
|
+
// Fall back to web‑auth.
|
|
394
|
+
isUsingWebAuth = true
|
|
395
|
+
let clientId = try self.clientID
|
|
231
396
|
let redirectUrl = try self.redirectURL
|
|
232
|
-
return "expo.modules.spotifyauth.\(redirectUrl.scheme ?? "unknown").\(clientID).refresh_token"
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
private func securelyStoreToken(_ session: SPTSession) {
|
|
236
|
-
// Pass token to JS and securely store refresh token
|
|
237
|
-
module?.onAccessTokenObtained(session.accessToken)
|
|
238
397
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
do {
|
|
243
|
-
let keychainKey = try getKeychainKey()
|
|
244
|
-
// Create a Keychain instance (using bundle identifier as the service)
|
|
245
|
-
let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
|
|
246
|
-
.accessibility(.afterFirstUnlock)
|
|
247
|
-
try keychain.set(refreshToken, key: keychainKey)
|
|
248
|
-
} catch {
|
|
249
|
-
print("Failed to store refresh token securely: \(error)")
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
private func cleanupPreviousSession() {
|
|
255
|
-
refreshTimer?.invalidate()
|
|
256
|
-
|
|
257
|
-
do {
|
|
258
|
-
let keychainKey = try getKeychainKey()
|
|
259
|
-
let keychain = Keychain(service: Bundle.main.bundleIdentifier ?? "com.superfan.app")
|
|
260
|
-
try keychain.remove(keychainKey)
|
|
261
|
-
} catch {
|
|
262
|
-
print("Failed to clear previous refresh token: \(error)")
|
|
263
|
-
}
|
|
398
|
+
let webAuthView = SpotifyOAuthView(appContext: nil)
|
|
399
|
+
webAuthView.delegate = self
|
|
400
|
+
self.webAuthView = webAuthView
|
|
264
401
|
|
|
265
|
-
|
|
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
|
-
}
|
|
402
|
+
let scopeStrings = scopes.scopesToStringArray()
|
|
274
403
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
public func initAuth(config: AuthorizeConfig) {
|
|
283
|
-
do {
|
|
284
|
-
// Store the current configuration
|
|
285
|
-
self.currentConfig = config
|
|
286
|
-
|
|
287
|
-
guard let sessionManager = self.sessionManager else {
|
|
288
|
-
throw SpotifyAuthError.sessionError("Session manager not initialized")
|
|
289
|
-
}
|
|
290
|
-
let scopes = try self.requestedScopes
|
|
291
|
-
isAuthenticating = true
|
|
292
|
-
|
|
293
|
-
// Check if Spotify app is installed
|
|
294
|
-
if sessionManager.isSpotifyAppInstalled {
|
|
295
|
-
// Use app-switch auth
|
|
296
|
-
if config.showDialog {
|
|
297
|
-
sessionManager.alwaysShowAuthorizationDialog = true
|
|
298
|
-
}
|
|
299
|
-
sessionManager.initiateSession(with: scopes, options: .default, campaign: config.campaign)
|
|
300
|
-
} else {
|
|
301
|
-
// Fall back to web auth
|
|
302
|
-
isUsingWebAuth = true
|
|
303
|
-
let clientId = try self.clientID
|
|
304
|
-
let redirectUrl = try self.redirectURL
|
|
305
|
-
|
|
306
|
-
// Create and configure web auth view
|
|
307
|
-
let webAuthView = SpotifyOAuthView(appContext: nil)
|
|
308
|
-
webAuthView.delegate = self
|
|
309
|
-
self.webAuthView = webAuthView
|
|
310
|
-
|
|
311
|
-
// Convert SPTScope to string array for web auth
|
|
312
|
-
let scopeStrings = scopes.scopesToStringArray()
|
|
313
|
-
|
|
314
|
-
// Start web auth flow
|
|
315
|
-
webAuthView.startOAuthFlow(
|
|
316
|
-
clientId: clientId,
|
|
317
|
-
redirectUri: redirectUrl.absoluteString,
|
|
318
|
-
scopes: scopeStrings
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
// Notify module to present web auth view
|
|
322
|
-
module?.presentWebAuth(webAuthView)
|
|
323
|
-
}
|
|
324
|
-
} catch {
|
|
325
|
-
isAuthenticating = false
|
|
326
|
-
handleError(error, context: "authentication")
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
private func retryAuthentication() {
|
|
331
|
-
guard !isAuthenticating else { return }
|
|
404
|
+
webAuthView.startOAuthFlow(
|
|
405
|
+
clientId: clientId,
|
|
406
|
+
redirectUri: redirectUrl.absoluteString,
|
|
407
|
+
scopes: scopeStrings
|
|
408
|
+
)
|
|
332
409
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
public func sessionManager(manager _: SPTSessionManager, didInitiate session: SPTSession) {
|
|
347
|
-
secureLog("Authentication successful")
|
|
348
|
-
isAuthenticating = false
|
|
349
|
-
currentSession = session
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
public func sessionManager(manager _: SPTSessionManager, didFailWith error: Error) {
|
|
353
|
-
secureLog("Authentication failed")
|
|
354
|
-
isAuthenticating = false
|
|
355
|
-
currentSession = nil
|
|
356
|
-
handleError(error, context: "authentication")
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
public func sessionManager(manager _: SPTSessionManager, didRenew session: SPTSession) {
|
|
360
|
-
secureLog("Token renewed successfully")
|
|
361
|
-
currentSession = session
|
|
362
|
-
}
|
|
410
|
+
module?.presentWebAuth(webAuthView)
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
isAuthenticating = false
|
|
414
|
+
handleError(error, context: "authentication")
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private func retryAuthentication() {
|
|
419
|
+
guard !isAuthenticating else { return }
|
|
363
420
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
421
|
+
do {
|
|
422
|
+
guard let sessionManager = self.sessionManager else {
|
|
423
|
+
throw SpotifyAuthError.sessionError("Session manager not initialized")
|
|
424
|
+
}
|
|
425
|
+
let scopes = try self.requestedScopes
|
|
426
|
+
isAuthenticating = true
|
|
427
|
+
sessionManager.initiateSession(with: scopes, options: .default, campaign: nil)
|
|
428
|
+
} catch {
|
|
429
|
+
isAuthenticating = false
|
|
430
|
+
handleError(error, context: "authentication_retry")
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// MARK: - SPTSessionManagerDelegate
|
|
435
|
+
|
|
436
|
+
public func sessionManager(manager: SPTSessionManager, didInitiate session: SPTSession) {
|
|
437
|
+
secureLog("Authentication successful")
|
|
438
|
+
isAuthenticating = false
|
|
439
|
+
if let sessionData = SpotifySessionData(session: session) {
|
|
440
|
+
currentSession = sessionData
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
public func sessionManager(manager: SPTSessionManager, didFailWith error: Error) {
|
|
445
|
+
secureLog("Authentication failed")
|
|
446
|
+
isAuthenticating = false
|
|
447
|
+
currentSession = nil
|
|
448
|
+
handleError(error, context: "authentication")
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
public func sessionManager(manager: SPTSessionManager, didRenew session: SPTSession) {
|
|
452
|
+
secureLog("Token renewed successfully")
|
|
453
|
+
if let sessionData = SpotifySessionData(session: session) {
|
|
454
|
+
currentSession = sessionData
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
public func clearSession() {
|
|
459
|
+
currentSession = nil
|
|
460
|
+
module?.onSignOut()
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// MARK: - SpotifyOAuthViewDelegate
|
|
464
|
+
|
|
465
|
+
func oauthView(_ view: SpotifyOAuthView, didReceiveCode code: String) {
|
|
466
|
+
// Exchange the code for tokens using tokenSwapURL.
|
|
467
|
+
exchangeCodeForToken(code)
|
|
468
|
+
cleanupWebAuth()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
func oauthView(_ view: SpotifyOAuthView, didFailWithError error: Error) {
|
|
472
|
+
handleError(error, context: "web_authentication")
|
|
473
|
+
cleanupWebAuth()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func oauthViewDidCancel(_ view: SpotifyOAuthView) {
|
|
477
|
+
module?.onAuthorizationError("User cancelled authentication")
|
|
478
|
+
cleanupWebAuth()
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private func cleanupWebAuth() {
|
|
482
|
+
isUsingWebAuth = false
|
|
483
|
+
webAuthView = nil
|
|
484
|
+
currentConfig = nil
|
|
485
|
+
module?.dismissWebAuth()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/// Exchange an authorization code for tokens.
|
|
489
|
+
private func exchangeCodeForToken(_ code: String) {
|
|
490
|
+
guard let swapURLString = try? self.tokenSwapURL,
|
|
491
|
+
let url = URL(string: swapURLString) else {
|
|
492
|
+
handleError(SpotifyAuthError.invalidConfiguration("Invalid token swap URL"), context: "token_exchange")
|
|
493
|
+
return
|
|
414
494
|
}
|
|
415
|
-
|
|
416
|
-
private func handleError(_ error: Error, context: String) {
|
|
417
|
-
let spotifyError: SpotifyAuthError
|
|
418
|
-
|
|
419
|
-
// Instead of switching on SPTError cases (which are no longer available),
|
|
420
|
-
// we simply wrap the error's description.
|
|
421
|
-
if error is SPTError {
|
|
422
|
-
spotifyError = .authenticationFailed(error.localizedDescription)
|
|
423
|
-
} else {
|
|
424
|
-
spotifyError = .authenticationFailed(error.localizedDescription)
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
secureLog("Error in \(context): \(spotifyError.localizedDescription)")
|
|
428
|
-
|
|
429
|
-
switch spotifyError.retryStrategy {
|
|
430
|
-
case .none:
|
|
431
|
-
module?.onAuthorizationError(spotifyError.localizedDescription)
|
|
432
|
-
cleanupPreviousSession()
|
|
433
|
-
|
|
434
|
-
case .retry(let attempts, let delay):
|
|
435
|
-
handleRetry(error: spotifyError, context: context, remainingAttempts: attempts, delay: delay)
|
|
436
|
-
|
|
437
|
-
case .exponentialBackoff(let maxAttempts, let initialDelay):
|
|
438
|
-
handleExponentialBackoff(error: spotifyError, context: context, remainingAttempts: maxAttempts, currentDelay: initialDelay)
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
private func handleRetry(error: SpotifyAuthError, context: String, remainingAttempts: Int, delay: TimeInterval) {
|
|
443
|
-
guard remainingAttempts > 0 else {
|
|
444
|
-
module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
|
|
445
|
-
cleanupPreviousSession()
|
|
446
|
-
return
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
secureLog("Retrying \(context) in \(delay) seconds. Attempts remaining: \(remainingAttempts)")
|
|
450
|
-
|
|
451
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
452
|
-
switch context {
|
|
453
|
-
case "token_refresh":
|
|
454
|
-
self?.refreshToken(retryCount: 3 - remainingAttempts)
|
|
455
|
-
case "authentication":
|
|
456
|
-
self?.retryAuthentication()
|
|
457
|
-
default:
|
|
458
|
-
break
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
private func handleExponentialBackoff(error: SpotifyAuthError, context: String, remainingAttempts: Int, currentDelay: TimeInterval) {
|
|
464
|
-
guard remainingAttempts > 0 else {
|
|
465
|
-
module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
|
|
466
|
-
cleanupPreviousSession()
|
|
467
|
-
return
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
secureLog("Retrying \(context) in \(currentDelay) seconds. Attempts remaining: \(remainingAttempts)")
|
|
471
|
-
|
|
472
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + currentDelay) { [weak self] in
|
|
473
|
-
switch context {
|
|
474
|
-
case "token_refresh":
|
|
475
|
-
self?.refreshToken(retryCount: 3 - remainingAttempts)
|
|
476
|
-
case "authentication":
|
|
477
|
-
self?.retryAuthentication()
|
|
478
|
-
default:
|
|
479
|
-
break
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
deinit {
|
|
485
|
-
cleanupPreviousSession()
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// MARK: - SpotifyOAuthViewDelegate
|
|
489
495
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
// Cleanup web view
|
|
495
|
-
cleanupWebAuth()
|
|
496
|
-
}
|
|
496
|
+
var request = URLRequest(url: url)
|
|
497
|
+
request.httpMethod = "POST"
|
|
498
|
+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
497
499
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
500
|
+
let params: [String: String]
|
|
501
|
+
do {
|
|
502
|
+
params = [
|
|
503
|
+
"grant_type": "authorization_code",
|
|
504
|
+
"code": code,
|
|
505
|
+
"redirect_uri": try self.redirectURL.absoluteString,
|
|
506
|
+
"client_id": try self.clientID
|
|
507
|
+
]
|
|
508
|
+
} catch {
|
|
509
|
+
handleError(error, context: "token_exchange")
|
|
510
|
+
return
|
|
501
511
|
}
|
|
502
512
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
513
|
+
let bodyString = params.map { "\($0)=\($1)" }.joined(separator: "&")
|
|
514
|
+
request.httpBody = bodyString.data(using: .utf8)
|
|
515
|
+
|
|
516
|
+
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
517
|
+
if let error = error {
|
|
518
|
+
self?.handleError(error, context: "token_exchange")
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
guard let data = data,
|
|
522
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
523
|
+
let accessToken = json["access_token"] as? String,
|
|
524
|
+
let refreshToken = json["refresh_token"] as? String,
|
|
525
|
+
let expiresIn = json["expires_in"] as? TimeInterval else {
|
|
526
|
+
self?.handleError(SpotifyAuthError.tokenError("Invalid token response"), context: "token_exchange")
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let expirationDate = Date(timeIntervalSinceNow: expiresIn)
|
|
531
|
+
let sessionData = SpotifySessionData(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expirationDate)
|
|
532
|
+
DispatchQueue.main.async {
|
|
533
|
+
self?.currentSession = sessionData
|
|
534
|
+
self?.module?.onAccessTokenObtained(accessToken)
|
|
535
|
+
}
|
|
506
536
|
}
|
|
507
537
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
538
|
+
task.resume()
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// MARK: - Helpers
|
|
542
|
+
|
|
543
|
+
private func stringToScope(scopeString: String) -> SPTScope? {
|
|
544
|
+
switch scopeString {
|
|
545
|
+
case "playlist-read-private":
|
|
546
|
+
return .playlistReadPrivate
|
|
547
|
+
case "playlist-read-collaborative":
|
|
548
|
+
return .playlistReadCollaborative
|
|
549
|
+
case "playlist-modify-public":
|
|
550
|
+
return .playlistModifyPublic
|
|
551
|
+
case "playlist-modify-private":
|
|
552
|
+
return .playlistModifyPrivate
|
|
553
|
+
case "user-follow-read":
|
|
554
|
+
return .userFollowRead
|
|
555
|
+
case "user-follow-modify":
|
|
556
|
+
return .userFollowModify
|
|
557
|
+
case "user-library-read":
|
|
558
|
+
return .userLibraryRead
|
|
559
|
+
case "user-library-modify":
|
|
560
|
+
return .userLibraryModify
|
|
561
|
+
case "user-read-birthdate":
|
|
562
|
+
return .userReadBirthDate
|
|
563
|
+
case "user-read-email":
|
|
564
|
+
return .userReadEmail
|
|
565
|
+
case "user-read-private":
|
|
566
|
+
return .userReadPrivate
|
|
567
|
+
case "user-top-read":
|
|
568
|
+
return .userTopRead
|
|
569
|
+
case "ugc-image-upload":
|
|
570
|
+
return .ugcImageUpload
|
|
571
|
+
case "streaming":
|
|
572
|
+
return .streaming
|
|
573
|
+
case "app-remote-control":
|
|
574
|
+
return .appRemoteControl
|
|
575
|
+
case "user-read-playback-state":
|
|
576
|
+
return .userReadPlaybackState
|
|
577
|
+
case "user-modify-playback-state":
|
|
578
|
+
return .userModifyPlaybackState
|
|
579
|
+
case "user-read-currently-playing":
|
|
580
|
+
return .userReadCurrentlyPlaying
|
|
581
|
+
case "user-read-recently-played":
|
|
582
|
+
return .userReadRecentlyPlayed
|
|
583
|
+
case "openid":
|
|
584
|
+
return .openid
|
|
585
|
+
default:
|
|
586
|
+
return nil
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private func handleError(_ error: Error, context: String) {
|
|
591
|
+
let spotifyError: SpotifyAuthError = .authenticationFailed(error.localizedDescription)
|
|
592
|
+
|
|
593
|
+
secureLog("Error in \(context): \(spotifyError.localizedDescription)")
|
|
594
|
+
|
|
595
|
+
switch spotifyError.retryStrategy {
|
|
596
|
+
case .none:
|
|
597
|
+
module?.onAuthorizationError(spotifyError.localizedDescription)
|
|
598
|
+
cleanupPreviousSession()
|
|
599
|
+
case .retry(let attempts, let delay):
|
|
600
|
+
handleRetry(error: spotifyError, context: context, remainingAttempts: attempts, delay: delay)
|
|
601
|
+
case .exponentialBackoff(let maxAttempts, let initialDelay):
|
|
602
|
+
handleExponentialBackoff(error: spotifyError, context: context, remainingAttempts: maxAttempts, currentDelay: initialDelay)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private func handleRetry(error: SpotifyAuthError, context: String, remainingAttempts: Int, delay: TimeInterval) {
|
|
607
|
+
guard remainingAttempts > 0 else {
|
|
608
|
+
module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
|
|
609
|
+
cleanupPreviousSession()
|
|
610
|
+
return
|
|
513
611
|
}
|
|
514
612
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
.joined(separator: "&")
|
|
535
|
-
.data(using: .utf8)
|
|
536
|
-
|
|
537
|
-
let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
|
|
538
|
-
if let error = error {
|
|
539
|
-
self?.handleError(error, context: "token_exchange")
|
|
540
|
-
return
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
guard let data = data,
|
|
544
|
-
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
545
|
-
let accessToken = json["access_token"] as? String,
|
|
546
|
-
let refreshToken = json["refresh_token"] as? String,
|
|
547
|
-
let expiresIn = json["expires_in"] as? TimeInterval else {
|
|
548
|
-
self?.handleError(SpotifyAuthError.tokenError("Invalid token response"), context: "token_exchange")
|
|
549
|
-
return
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Create session from token response
|
|
553
|
-
let expirationDate = Date(timeIntervalSinceNow: expiresIn)
|
|
554
|
-
if let session = SPTSession(accessToken: accessToken, refreshToken: refreshToken, expirationDate: expirationDate) {
|
|
555
|
-
DispatchQueue.main.async {
|
|
556
|
-
self?.currentSession = session
|
|
557
|
-
self?.module?.onAccessTokenObtained(accessToken)
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
task.resume()
|
|
613
|
+
secureLog("Retrying \(context) in \(delay) seconds. Attempts remaining: \(remainingAttempts)")
|
|
614
|
+
|
|
615
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
616
|
+
switch context {
|
|
617
|
+
case "token_refresh":
|
|
618
|
+
self?.refreshToken(retryCount: 3 - remainingAttempts)
|
|
619
|
+
case "authentication":
|
|
620
|
+
self?.retryAuthentication()
|
|
621
|
+
default:
|
|
622
|
+
break
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private func handleExponentialBackoff(error: SpotifyAuthError, context: String, remainingAttempts: Int, currentDelay: TimeInterval) {
|
|
628
|
+
guard remainingAttempts > 0 else {
|
|
629
|
+
module?.onAuthorizationError("\(error.localizedDescription) (Max retries reached)")
|
|
630
|
+
cleanupPreviousSession()
|
|
631
|
+
return
|
|
563
632
|
}
|
|
633
|
+
|
|
634
|
+
secureLog("Retrying \(context) in \(currentDelay) seconds. Attempts remaining: \(remainingAttempts)")
|
|
635
|
+
|
|
636
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + currentDelay) { [weak self] in
|
|
637
|
+
switch context {
|
|
638
|
+
case "token_refresh":
|
|
639
|
+
self?.refreshToken(retryCount: 3 - remainingAttempts)
|
|
640
|
+
case "authentication":
|
|
641
|
+
self?.retryAuthentication()
|
|
642
|
+
default:
|
|
643
|
+
break
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private func secureLog(_ message: String) {
|
|
649
|
+
print("[SpotifyAuthAuth] \(message)")
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
deinit {
|
|
653
|
+
cleanupPreviousSession()
|
|
654
|
+
}
|
|
564
655
|
}
|
|
565
656
|
|
|
566
|
-
//
|
|
657
|
+
// MARK: - Helper Extension
|
|
658
|
+
|
|
567
659
|
extension SPTScope {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
660
|
+
/// Converts an SPTScope value into an array of scope strings.
|
|
661
|
+
func scopesToStringArray() -> [String] {
|
|
662
|
+
var scopes: [String] = []
|
|
663
|
+
if contains(.playlistReadPrivate) { scopes.append("playlist-read-private") }
|
|
664
|
+
if contains(.playlistReadCollaborative) { scopes.append("playlist-read-collaborative") }
|
|
665
|
+
if contains(.playlistModifyPublic) { scopes.append("playlist-modify-public") }
|
|
666
|
+
if contains(.playlistModifyPrivate) { scopes.append("playlist-modify-private") }
|
|
667
|
+
if contains(.userFollowRead) { scopes.append("user-follow-read") }
|
|
668
|
+
if contains(.userFollowModify) { scopes.append("user-follow-modify") }
|
|
669
|
+
if contains(.userLibraryRead) { scopes.append("user-library-read") }
|
|
670
|
+
if contains(.userLibraryModify) { scopes.append("user-library-modify") }
|
|
671
|
+
if contains(.userReadBirthDate) { scopes.append("user-read-birthdate") }
|
|
672
|
+
if contains(.userReadEmail) { scopes.append("user-read-email") }
|
|
673
|
+
if contains(.userReadPrivate) { scopes.append("user-read-private") }
|
|
674
|
+
if contains(.userTopRead) { scopes.append("user-top-read") }
|
|
675
|
+
if contains(.ugcImageUpload) { scopes.append("ugc-image-upload") }
|
|
676
|
+
if contains(.streaming) { scopes.append("streaming") }
|
|
677
|
+
if contains(.appRemoteControl) { scopes.append("app-remote-control") }
|
|
678
|
+
if contains(.userReadPlaybackState) { scopes.append("user-read-playback-state") }
|
|
679
|
+
if contains(.userModifyPlaybackState) { scopes.append("user-modify-playback-state") }
|
|
680
|
+
if contains(.userReadCurrentlyPlaying) { scopes.append("user-read-currently-playing") }
|
|
681
|
+
if contains(.userReadRecentlyPlayed) { scopes.append("user-read-recently-played") }
|
|
682
|
+
if contains(.openid) { scopes.append("openid") }
|
|
683
|
+
return scopes
|
|
684
|
+
}
|
|
594
685
|
}
|
|
@@ -92,8 +92,8 @@ public class SpotifyAuthModule: Module {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Enables the module to be used as a native view.
|
|
95
|
-
View(
|
|
96
|
-
Prop("name") { (_:
|
|
95
|
+
View(SpotifyOAuthView.self) {
|
|
96
|
+
Prop("name") { (_: SpotifyOAuthView, prop: String) in
|
|
97
97
|
secureLog("View prop updated: \(prop)")
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -158,8 +158,7 @@ public class SpotifyAuthModule: Module {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
func presentWebAuth(_ webAuthView: SpotifyOAuthView) {
|
|
161
|
-
|
|
162
|
-
guard let topViewController = UIApplication.shared.keyWindow?.rootViewController?.topMostViewController() else {
|
|
161
|
+
guard let topViewController = UIApplication.shared.currentKeyWindow?.rootViewController?.topMostViewController() else {
|
|
163
162
|
onAuthorizationError("Could not present web authentication")
|
|
164
163
|
return
|
|
165
164
|
}
|
|
@@ -178,7 +177,7 @@ public class SpotifyAuthModule: Module {
|
|
|
178
177
|
func dismissWebAuth() {
|
|
179
178
|
// Find and dismiss the web auth view controller
|
|
180
179
|
DispatchQueue.main.async {
|
|
181
|
-
UIApplication.shared.
|
|
180
|
+
UIApplication.shared.currentKeyWindow?.rootViewController?.topMostViewController().dismiss(animated: true)
|
|
182
181
|
}
|
|
183
182
|
}
|
|
184
183
|
}
|
|
@@ -198,3 +197,18 @@ extension UIViewController {
|
|
|
198
197
|
return self
|
|
199
198
|
}
|
|
200
199
|
}
|
|
200
|
+
|
|
201
|
+
// Extension to safely get key window on iOS 13+
|
|
202
|
+
extension UIApplication {
|
|
203
|
+
var currentKeyWindow: UIWindow? {
|
|
204
|
+
if #available(iOS 13, *) {
|
|
205
|
+
return self.connectedScenes
|
|
206
|
+
.filter { $0.activationState == .foregroundActive }
|
|
207
|
+
.compactMap { $0 as? UIWindowScene }
|
|
208
|
+
.flatMap { $0.windows }
|
|
209
|
+
.first(where: { $0.isKeyWindow })
|
|
210
|
+
} else {
|
|
211
|
+
return self.keyWindow
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|