@superfan-app/spotify-auth 0.1.56 → 0.1.58

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.
@@ -0,0 +1,100 @@
1
+ import ExpoModulesCore
2
+ import AuthenticationServices
3
+
4
+ /// A lightweight wrapper around ASWebAuthenticationSession for Spotify OAuth
5
+ final class SpotifyASWebAuthSession {
6
+ private var authSession: ASWebAuthenticationSession?
7
+ private var presentationContextProvider: ASWebAuthenticationPresentationContextProviding?
8
+ private var isAuthenticating = false
9
+
10
+ private func secureLog(_ message: String) {
11
+ #if DEBUG
12
+ print("[SpotifyASWebAuth] \(message)")
13
+ #endif
14
+ }
15
+
16
+ func startAuthFlow(
17
+ authUrl: URL,
18
+ redirectScheme: String?,
19
+ preferEphemeral: Bool = true,
20
+ completion: @escaping (Result<URL, Error>) -> Void
21
+ ) {
22
+ guard !isAuthenticating else {
23
+ completion(.failure(SpotifyAuthError.authorizationError("Authentication already in progress")))
24
+ return
25
+ }
26
+
27
+ isAuthenticating = true
28
+
29
+ // Create the auth session
30
+ authSession = ASWebAuthenticationSession(
31
+ url: authUrl,
32
+ callbackURLScheme: redirectScheme
33
+ ) { [weak self] callbackURL, error in
34
+ self?.isAuthenticating = false
35
+
36
+ if let error = error {
37
+ if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
38
+ completion(.failure(SpotifyAuthError.userCancelled))
39
+ } else {
40
+ completion(.failure(SpotifyAuthError.networkError(error.localizedDescription)))
41
+ }
42
+ return
43
+ }
44
+
45
+ guard let callbackURL = callbackURL else {
46
+ completion(.failure(SpotifyAuthError.authorizationError("No callback URL received")))
47
+ return
48
+ }
49
+
50
+ completion(.success(callbackURL))
51
+ }
52
+
53
+ // Configure the session
54
+ authSession?.prefersEphemeralWebBrowserSession = preferEphemeral
55
+
56
+ // Set up presentation context
57
+ let provider = PresentationContextProvider()
58
+ presentationContextProvider = provider
59
+ authSession?.presentationContextProvider = provider
60
+
61
+ // Start the session
62
+ DispatchQueue.main.async { [weak self] in
63
+ guard let self = self,
64
+ let authSession = self.authSession,
65
+ authSession.start() else {
66
+ self?.isAuthenticating = false
67
+ completion(.failure(SpotifyAuthError.authorizationError("Failed to start auth session")))
68
+ return
69
+ }
70
+ self.secureLog("Auth session started successfully")
71
+ }
72
+ }
73
+
74
+ func cancel() {
75
+ authSession?.cancel()
76
+ isAuthenticating = false
77
+ authSession = nil
78
+ presentationContextProvider = nil
79
+ }
80
+ }
81
+
82
+ // MARK: - Presentation Context Provider
83
+
84
+ private class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
85
+ func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
86
+ if #available(iOS 13.0, *) {
87
+ // Get the active window scene
88
+ let scene = UIApplication.shared.connectedScenes
89
+ .filter { $0.activationState == .foregroundActive }
90
+ .first(where: { $0 is UIWindowScene }) as? UIWindowScene
91
+
92
+ // Get the key window from the scene
93
+ let keyWindow = scene?.windows.first(where: { $0.isKeyWindow })
94
+ return keyWindow ?? ASPresentationAnchor()
95
+ } else {
96
+ // Fallback for older iOS versions
97
+ return UIApplication.shared.keyWindow ?? ASPresentationAnchor()
98
+ }
99
+ }
100
+ }
@@ -38,6 +38,9 @@ enum SpotifyAuthError: Error {
38
38
  case sessionError(String)
39
39
  case networkError(String)
40
40
  case recoverable(String, RetryStrategy)
41
+ case userCancelled
42
+ case authorizationError(String)
43
+ case invalidRedirectURL
41
44
 
42
45
  enum RetryStrategy {
43
46
  case none
@@ -74,6 +77,12 @@ enum SpotifyAuthError: Error {
74
77
  return "Network error: \(reason). Please check your internet connection."
75
78
  case .recoverable(let message, _):
76
79
  return message
80
+ case .userCancelled:
81
+ return "User cancelled the authentication process."
82
+ case .authorizationError(let reason):
83
+ return "Authorization error: \(reason). Please try logging in again."
84
+ case .invalidRedirectURL:
85
+ return "Invalid redirect URL. Please check your app.json configuration."
77
86
  }
78
87
  }
79
88
 
@@ -91,12 +100,12 @@ enum SpotifyAuthError: Error {
91
100
  }
92
101
  }
93
102
 
94
- final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthViewDelegate {
103
+ final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate {
95
104
  /// A weak reference to our module's JS interface.
96
105
  weak var module: SpotifyAuthModule?
97
106
 
98
- /// For web‑auth we present our own OAuth view.
99
- private var webAuthView: SpotifyOAuthView?
107
+ /// For web‑auth we use ASWebAuthenticationSession
108
+ private var webAuthSession: SpotifyASWebAuthSession?
100
109
  private var isUsingWebAuth = false
101
110
 
102
111
  /// Stores the active configuration from JS.
@@ -416,20 +425,7 @@ final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthVi
416
425
  DispatchQueue.main.async { [weak self] in
417
426
  guard let self = self else { return }
418
427
 
419
- let webView = SpotifyOAuthView(appContext: nil)
420
- webView.delegate = self
421
- self.webAuthView = webView
422
- self.isUsingWebAuth = true
423
-
424
- webView.startOAuthFlow(
425
- clientId: clientId,
426
- redirectUri: redirectUrl.absoluteString,
427
- scopes: scopeStrings,
428
- showDialog: showDialog,
429
- campaign: campaign
430
- )
431
-
432
- self.module?.presentWebAuth(webView)
428
+ self.initWebAuth(clientId: clientId, redirectUrl: redirectUrl, scopes: scopeStrings, showDialog: showDialog, campaign: campaign)
433
429
  }
434
430
  }
435
431
  } catch {
@@ -487,29 +483,19 @@ final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthVi
487
483
  module?.onSignOut()
488
484
  }
489
485
 
490
- // MARK: - SpotifyOAuthViewDelegate
491
-
492
- func oauthView(_ view: SpotifyOAuthView, didReceiveCode code: String) {
493
- // Exchange the code for tokens using tokenSwapURL.
494
- exchangeCodeForToken(code)
495
- cleanupWebAuth()
496
- }
497
-
498
- func oauthView(_ view: SpotifyOAuthView, didFailWithError error: Error) {
499
- handleError(error, context: "web_authentication")
500
- cleanupWebAuth()
501
- }
486
+ // MARK: - Web Auth Cancellation
502
487
 
503
- func oauthViewDidCancel(_ view: SpotifyOAuthView) {
504
- module?.onAuthorizationError(SpotifyAuthError.authenticationFailed("User cancelled authentication"))
488
+ func cancelWebAuth() {
489
+ webAuthSession?.cancel()
490
+ module?.onAuthorizationError(SpotifyAuthError.userCancelled)
505
491
  cleanupWebAuth()
506
492
  }
507
493
 
508
494
  private func cleanupWebAuth() {
509
495
  isUsingWebAuth = false
510
- webAuthView = nil
496
+ webAuthSession?.cancel()
497
+ webAuthSession = nil
511
498
  currentConfig = nil
512
- module?.dismissWebAuth()
513
499
  }
514
500
 
515
501
  /// Exchange an authorization code for tokens.
@@ -609,16 +595,6 @@ final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthVi
609
595
  task.resume()
610
596
  }
611
597
 
612
- // MARK: - Web Auth Cancellation
613
-
614
- func webAuthViewDidCancel() {
615
- guard let webView = webAuthView else {
616
- module?.onAuthorizationError(SpotifyAuthError.sessionError("Web auth view not found"))
617
- return
618
- }
619
- oauthViewDidCancel(webView)
620
- }
621
-
622
598
  // MARK: - Helpers
623
599
 
624
600
  private func stringToScope(scopeString: String) -> SPTScope? {
@@ -771,6 +747,75 @@ final class SpotifyAuthAuth: NSObject, SPTSessionManagerDelegate, SpotifyOAuthVi
771
747
  }
772
748
  }
773
749
  }
750
+
751
+ private func initWebAuth(clientId: String, redirectUrl: URL, scopes: [String], showDialog: Bool, campaign: String?) {
752
+ guard var urlComponents = URLComponents(string: "https://accounts.spotify.com/authorize") else {
753
+ module?.onAuthorizationError(SpotifyAuthError.invalidRedirectURL)
754
+ return
755
+ }
756
+
757
+ // Generate state for CSRF protection
758
+ let state = UUID().uuidString
759
+
760
+ var queryItems = [
761
+ URLQueryItem(name: "client_id", value: clientId),
762
+ URLQueryItem(name: "response_type", value: "code"),
763
+ URLQueryItem(name: "redirect_uri", value: redirectUrl.absoluteString),
764
+ URLQueryItem(name: "state", value: state),
765
+ URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
766
+ URLQueryItem(name: "show_dialog", value: showDialog ? "true" : "false")
767
+ ]
768
+
769
+ if let campaign = campaign {
770
+ queryItems.append(URLQueryItem(name: "campaign", value: campaign))
771
+ }
772
+
773
+ urlComponents.queryItems = queryItems
774
+
775
+ guard let authUrl = urlComponents.url else {
776
+ module?.onAuthorizationError(SpotifyAuthError.invalidRedirectURL)
777
+ return
778
+ }
779
+
780
+ // Create and start web auth session
781
+ webAuthSession = SpotifyASWebAuthSession()
782
+ webAuthSession?.startAuthFlow(
783
+ authUrl: authUrl,
784
+ redirectScheme: redirectUrl.scheme,
785
+ preferEphemeral: true
786
+ ) { [weak self] result in
787
+ switch result {
788
+ case .success(let callbackURL):
789
+ guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: true),
790
+ let queryItems = components.queryItems else {
791
+ self?.module?.onAuthorizationError(SpotifyAuthError.invalidRedirectURL)
792
+ return
793
+ }
794
+
795
+ // Verify state parameter to prevent CSRF attacks
796
+ guard let returnedState = queryItems.first(where: { $0.name == "state" })?.value,
797
+ returnedState == state else {
798
+ self?.module?.onAuthorizationError(SpotifyAuthError.stateMismatch)
799
+ return
800
+ }
801
+
802
+ if let error = queryItems.first(where: { $0.name == "error" })?.value {
803
+ if error == "access_denied" {
804
+ self?.module?.onAuthorizationError(SpotifyAuthError.userCancelled)
805
+ } else {
806
+ self?.module?.onAuthorizationError(SpotifyAuthError.authorizationError(error))
807
+ }
808
+ } else if let code = queryItems.first(where: { $0.name == "code" })?.value {
809
+ self?.exchangeCodeForToken(code)
810
+ }
811
+
812
+ case .failure(let error):
813
+ self?.module?.onAuthorizationError(error)
814
+ }
815
+
816
+ self?.cleanupWebAuth()
817
+ }
818
+ }
774
819
  }
775
820
 
776
821
  // MARK: - Helper Extension
@@ -75,15 +75,9 @@ public class SpotifyAuthModule: Module {
75
75
  }
76
76
  }
77
77
 
78
- // Enables the module to be used as a native view.
79
- View(SpotifyOAuthView.self) {
80
- Events(spotifyAuthorizationEventName)
81
-
82
- Prop("name") { (_: SpotifyOAuthView, _: String) in
83
- DispatchQueue.main.async {
84
- secureLog("View prop updated")
85
- }
86
- }
78
+ // Update the dismissal function
79
+ AsyncFunction("dismissAuthSession") {
80
+ self.spotifyAuth.cancelWebAuth()
87
81
  }
88
82
  }
89
83
 
@@ -272,49 +266,6 @@ public class SpotifyAuthModule: Module {
272
266
  return ("recoverable_error", "recoverable_unknown")
273
267
  }
274
268
  }
275
-
276
- func presentWebAuth(_ webAuthView: SpotifyOAuthView) {
277
- // Ensure we're on the main thread for all UI operations
278
- if !Thread.isMainThread {
279
- DispatchQueue.main.async { [weak self] in
280
- self?.presentWebAuth(webAuthView)
281
- }
282
- return
283
- }
284
-
285
- guard let topViewController = UIApplication.shared.currentKeyWindow?.rootViewController?.topMostViewController() else {
286
- onAuthorizationError(SpotifyAuthError.sessionError("Could not present web authentication"))
287
- return
288
- }
289
-
290
- // Create and configure container view controller
291
- let containerVC = UIViewController()
292
- containerVC.view = webAuthView
293
-
294
- // Create navigation controller and configure it
295
- let navigationController = UINavigationController(rootViewController: containerVC)
296
- navigationController.modalPresentationStyle = .fullScreen
297
-
298
- // Add cancel button
299
- let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismissWebAuthWithCancel))
300
- containerVC.navigationItem.leftBarButtonItem = cancelButton
301
- containerVC.navigationItem.title = "Spotify Login"
302
-
303
- // Present the web auth view
304
- topViewController.present(navigationController, animated: true, completion: nil)
305
- }
306
-
307
- @objc private func dismissWebAuthWithCancel() {
308
- spotifyAuth.webAuthViewDidCancel()
309
- dismissWebAuth()
310
- }
311
-
312
- func dismissWebAuth() {
313
- // Find and dismiss the web auth view controller
314
- DispatchQueue.main.async {
315
- UIApplication.shared.currentKeyWindow?.rootViewController?.topMostViewController().dismiss(animated: true)
316
- }
317
- }
318
269
  }
319
270
 
320
271
  // Helper extension to find top-most view controller
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superfan-app/spotify-auth",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
4
4
  "description": "Spotify OAuth module for Expo",
5
5
  "main": "src/index.tsx",
6
6
  "types": "build/index.d.ts",
@@ -1,379 +0,0 @@
1
- import ExpoModulesCore
2
- import WebKit
3
-
4
- protocol SpotifyOAuthViewDelegate: AnyObject {
5
- func oauthView(_ view: SpotifyOAuthView, didReceiveCode code: String)
6
- func oauthView(_ view: SpotifyOAuthView, didFailWithError error: Error)
7
- func oauthViewDidCancel(_ view: SpotifyOAuthView)
8
- }
9
-
10
- enum SpotifyOAuthError: Error {
11
- case invalidRedirectURL
12
- case stateMismatch
13
- case timeout
14
- case userCancelled
15
- case networkError(Error)
16
- case authorizationError(String)
17
-
18
- var localizedDescription: String {
19
- switch self {
20
- case .invalidRedirectURL:
21
- return "Invalid redirect URL"
22
- case .stateMismatch:
23
- return "State mismatch - possible CSRF attack"
24
- case .timeout:
25
- return "Authentication timed out"
26
- case .userCancelled:
27
- return "User cancelled authentication"
28
- case .networkError(let error):
29
- return "Network error: \(error.localizedDescription)"
30
- case .authorizationError(let message):
31
- return "Authorization error: \(message)"
32
- }
33
- }
34
- }
35
-
36
- // This view will be used as a native component for web-based OAuth when Spotify app isn't installed
37
- class SpotifyOAuthView: ExpoView {
38
- weak var delegate: SpotifyOAuthViewDelegate?
39
- private var webView: WKWebView!
40
- private let state: String
41
- private var isAuthenticating = false
42
- private var expectedRedirectScheme: String?
43
- private var authTimeout: Timer?
44
- private static let authTimeoutInterval: TimeInterval = 300 // 5 minutes
45
- private var observerToken: NSKeyValueObservation?
46
-
47
- private func secureLog(_ message: String) {
48
- #if DEBUG
49
- print("[SpotifyOAuthView] \(message)")
50
- #endif
51
- }
52
-
53
- required init(appContext: AppContext? = nil) {
54
- // Generate a random state string for CSRF protection
55
- self.state = UUID().uuidString
56
- super.init(appContext: appContext)
57
- secureLog("Initializing SpotifyOAuthView with state: \(String(state.prefix(8)))...")
58
- setupWebView()
59
- }
60
-
61
- required init?(coder: NSCoder) {
62
- fatalError("init(coder:) has not been implemented")
63
- }
64
-
65
- override func layoutSubviews() {
66
- if !Thread.isMainThread {
67
- DispatchQueue.main.async { [weak self] in
68
- self?.layoutSubviews()
69
- }
70
- return
71
- }
72
- super.layoutSubviews()
73
- }
74
-
75
- override func didMoveToWindow() {
76
- if !Thread.isMainThread {
77
- DispatchQueue.main.async { [weak self] in
78
- self?.didMoveToWindow()
79
- }
80
- return
81
- }
82
- super.didMoveToWindow()
83
- }
84
-
85
- override func didMoveToSuperview() {
86
- if !Thread.isMainThread {
87
- DispatchQueue.main.async { [weak self] in
88
- self?.didMoveToSuperview()
89
- }
90
- return
91
- }
92
- super.didMoveToSuperview()
93
- }
94
-
95
- private func setupWebView() {
96
- // Ensure we're on the main thread for UI setup
97
- if !Thread.isMainThread {
98
- DispatchQueue.main.async { [weak self] in
99
- self?.setupWebView()
100
- }
101
- return
102
- }
103
-
104
- secureLog("Setting up WebView configuration...")
105
- // Create a configuration that prevents data persistence
106
- let config: WKWebViewConfiguration = {
107
- let configuration = WKWebViewConfiguration()
108
- configuration.processPool = WKProcessPool() // Create a new process pool
109
- let prefs = WKWebpagePreferences()
110
- prefs.allowsContentJavaScript = true
111
- configuration.defaultWebpagePreferences = prefs
112
-
113
- // Ensure cookies and data are not persisted
114
- let dataStore = WKWebsiteDataStore.nonPersistent()
115
- configuration.websiteDataStore = dataStore
116
- self.secureLog("WebView configuration created with non-persistent data store")
117
- return configuration
118
- }()
119
-
120
- // Initialize webview with error handling
121
- do {
122
- webView = WKWebView(frame: .zero, configuration: config)
123
- guard webView != nil else {
124
- throw NSError(domain: "SpotifyAuth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize WebView"])
125
- }
126
- self.secureLog("WebView successfully initialized")
127
-
128
- webView.navigationDelegate = self
129
- webView.allowsBackForwardNavigationGestures = true
130
- webView.customUserAgent = "SpotifyAuth-iOS/1.0"
131
-
132
- // Create UI elements
133
- let activityIndicator = UIActivityIndicatorView(style: .medium)
134
- activityIndicator.translatesAutoresizingMaskIntoConstraints = false
135
- activityIndicator.hidesWhenStopped = true
136
-
137
- // Perform all UI operations in a single block
138
- DispatchQueue.main.async { [weak self] in
139
- guard let self = self else { return }
140
-
141
- self.addSubview(self.webView)
142
- self.addSubview(activityIndicator)
143
-
144
- // Setup constraints
145
- self.webView.translatesAutoresizingMaskIntoConstraints = false
146
- NSLayoutConstraint.activate([
147
- self.webView.topAnchor.constraint(equalTo: self.topAnchor),
148
- self.webView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
149
- self.webView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
150
- self.webView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
151
-
152
- activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor),
153
- activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor)
154
- ])
155
-
156
- // Setup modern KVO observation
157
- self.observerToken = self.webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
158
- DispatchQueue.main.async {
159
- if let activityIndicator = self?.subviews.first(where: { $0 is UIActivityIndicatorView }) as? UIActivityIndicatorView {
160
- if self?.webView.isLoading == true {
161
- activityIndicator.startAnimating()
162
- } else {
163
- activityIndicator.stopAnimating()
164
- }
165
- }
166
- }
167
- }
168
- }
169
- } catch {
170
- secureLog("Failed to setup WebView: \(error.localizedDescription)")
171
- DispatchQueue.main.async { [weak self] in
172
- guard let self = self else { return }
173
- self.delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.authorizationError("Failed to initialize web view"))
174
- }
175
- }
176
- }
177
-
178
- func startOAuthFlow(clientId: String, redirectUri: String, scopes: [String], showDialog: Bool = false, campaign: String? = nil) {
179
- // Ensure we're on the main thread - WebView setup must be done on the main thread
180
- if !Thread.isMainThread {
181
- DispatchQueue.main.async { [weak self] in
182
- self?.startOAuthFlow(clientId: clientId, redirectUri: redirectUri, scopes: scopes, showDialog: showDialog, campaign: campaign)
183
- }
184
- return
185
- }
186
-
187
- guard !isAuthenticating else { return }
188
- isAuthenticating = true
189
-
190
- // Extract and store the redirect scheme
191
- guard let url = URL(string: redirectUri),
192
- let scheme = url.scheme else {
193
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.invalidRedirectURL)
194
- return
195
- }
196
- expectedRedirectScheme = scheme
197
-
198
- // Start auth timeout timer
199
- startAuthTimeout()
200
-
201
- // Clear any existing cookies/data to ensure a fresh login
202
- // Wait for completion before initiating the auth request
203
- WKWebsiteDataStore.default().removeData(
204
- ofTypes: [WKWebsiteDataTypeCookies, WKWebsiteDataTypeSessionStorage],
205
- modifiedSince: Date(timeIntervalSince1970: 0)
206
- ) { [weak self] in
207
- // Ensure we're still in a valid state after the async operation
208
- guard let self = self, self.isAuthenticating else { return }
209
-
210
- DispatchQueue.main.async {
211
- self.initiateAuthRequest(
212
- clientId: clientId,
213
- redirectUri: redirectUri,
214
- scopes: scopes,
215
- showDialog: showDialog,
216
- campaign: campaign
217
- )
218
- }
219
- }
220
- }
221
-
222
- private func startAuthTimeout() {
223
- authTimeout?.invalidate()
224
- authTimeout = Timer.scheduledTimer(withTimeInterval: Self.authTimeoutInterval, repeats: false) { [weak self] _ in
225
- self?.handleTimeout()
226
- }
227
- }
228
-
229
- private func handleTimeout() {
230
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.timeout)
231
- cleanup()
232
- }
233
-
234
- private func cleanup() {
235
- secureLog("Cleaning up authentication session")
236
- isAuthenticating = false
237
- authTimeout?.invalidate()
238
- authTimeout = nil
239
- expectedRedirectScheme = nil
240
-
241
- // Clear web view data
242
- WKWebsiteDataStore.default().removeData(
243
- ofTypes: [WKWebsiteDataTypeCookies, WKWebsiteDataTypeSessionStorage],
244
- modifiedSince: Date(timeIntervalSince1970: 0)
245
- ) { }
246
- }
247
-
248
- private func initiateAuthRequest(clientId: String, redirectUri: String, scopes: [String], showDialog: Bool, campaign: String?) {
249
- guard var urlComponents = URLComponents(string: "https://accounts.spotify.com/authorize") else {
250
- isAuthenticating = false
251
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.invalidRedirectURL)
252
- return
253
- }
254
-
255
- // Verify webView is properly initialized
256
- guard let webView = self.webView else {
257
- secureLog("Error: WebView not initialized when attempting to load auth request")
258
- isAuthenticating = false
259
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.authorizationError("WebView not initialized"))
260
- return
261
- }
262
-
263
- var queryItems = [
264
- URLQueryItem(name: "client_id", value: clientId),
265
- URLQueryItem(name: "response_type", value: "code"),
266
- URLQueryItem(name: "redirect_uri", value: redirectUri),
267
- URLQueryItem(name: "state", value: state),
268
- URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
269
- URLQueryItem(name: "show_dialog", value: showDialog ? "true" : "false")
270
- ]
271
-
272
- if let campaign = campaign {
273
- queryItems.append(URLQueryItem(name: "campaign", value: campaign))
274
- }
275
-
276
- urlComponents.queryItems = queryItems
277
-
278
- guard let url = urlComponents.url else {
279
- isAuthenticating = false
280
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.invalidRedirectURL)
281
- return
282
- }
283
-
284
- let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)
285
- secureLog("Initiating auth request to URL: \(url.absoluteString)")
286
-
287
- DispatchQueue.main.async {
288
- guard Thread.isMainThread else {
289
- assertionFailure("WebView load not on main thread despite DispatchQueue.main.async")
290
- return
291
- }
292
-
293
- if webView.isLoading {
294
- self.secureLog("Warning: WebView is already loading content, stopping previous load")
295
- webView.stopLoading()
296
- }
297
-
298
- webView.load(request)
299
- self.secureLog("Auth request load initiated")
300
- }
301
- }
302
-
303
- deinit {
304
- observerToken?.invalidate()
305
- authTimeout?.invalidate()
306
- }
307
- }
308
-
309
- extension SpotifyOAuthView: WKNavigationDelegate {
310
- func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
311
- guard let url = navigationAction.request.url else {
312
- decisionHandler(.allow)
313
- return
314
- }
315
-
316
- // Check if the URL matches our redirect URI scheme
317
- if let expectedScheme = expectedRedirectScheme,
318
- url.scheme == expectedScheme {
319
- guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
320
- let queryItems = components.queryItems else {
321
- decisionHandler(.cancel)
322
- return
323
- }
324
-
325
- // Verify state parameter to prevent CSRF attacks
326
- guard let returnedState = queryItems.first(where: { $0.name == "state" })?.value,
327
- returnedState == state else {
328
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.stateMismatch)
329
- decisionHandler(.cancel)
330
- cleanup()
331
- return
332
- }
333
-
334
- cleanup()
335
-
336
- if let error = queryItems.first(where: { $0.name == "error" })?.value {
337
- if error == "access_denied" {
338
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.userCancelled)
339
- } else {
340
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.authorizationError(error))
341
- }
342
- } else if let code = queryItems.first(where: { $0.name == "code" })?.value {
343
- delegate?.oauthView(self, didReceiveCode: code)
344
- }
345
-
346
- decisionHandler(.cancel)
347
- return
348
- }
349
-
350
- // Only allow navigation to Spotify domains and our redirect URI
351
- let allowedDomains = ["accounts.spotify.com", "spotify.com"]
352
- if let host = url.host,
353
- allowedDomains.contains(where: { host.hasSuffix($0) }) {
354
- decisionHandler(.allow)
355
- } else {
356
- decisionHandler(.cancel)
357
- }
358
- }
359
-
360
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
361
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.networkError(error))
362
- cleanup()
363
- }
364
-
365
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
366
- delegate?.oauthView(self, didFailWithError: SpotifyOAuthError.networkError(error))
367
- cleanup()
368
- }
369
-
370
- func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
371
- // Ensure proper SSL/TLS handling
372
- if let serverTrust = challenge.protectionSpace.serverTrust {
373
- let credential = URLCredential(trust: serverTrust)
374
- completionHandler(.useCredential, credential)
375
- } else {
376
- completionHandler(.cancelAuthenticationChallenge, nil)
377
- }
378
- }
379
- }