@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.
- package/ios/SpotifyASWebAuthSession.swift +100 -0
- package/ios/SpotifyAuthAuth.swift +88 -43
- package/ios/SpotifyAuthModule.swift +3 -52
- package/package.json +1 -1
- package/ios/SpotifyOAuthView.swift +0 -379
|
@@ -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
|
|
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
|
|
99
|
-
private var
|
|
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
|
-
|
|
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: -
|
|
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
|
|
504
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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,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
|
-
}
|