diff --git a/Spotifly/KeychainManager.swift b/Spotifly/KeychainManager.swift index 893ffd1..9200ff1 100644 --- a/Spotifly/KeychainManager.swift +++ b/Spotifly/KeychainManager.swift @@ -10,10 +10,16 @@ import Security /// Manages secure storage of authentication tokens in the Keychain enum KeychainManager { - private static let service = "com.spotifly.oauth" + // MARK: - Keychain Keys + + private static let oauthService = "com.spotifly.oauth" + private static let configService = "com.spotifly.config" + private static let accessTokenKey = "spotify_access_token" private static let refreshTokenKey = "spotify_refresh_token" private static let expiresAtKey = "spotify_expires_at" + private static let customClientIdKey = "spotify_custom_client_id" + private static let useCustomClientIdKey = "spotify_use_custom_client_id" // MARK: - Public API @@ -78,9 +84,13 @@ enum KeychainManager { let isExpired = result.expiresIn < 300 // 5 minutes if isExpired, let refreshToken = result.refreshToken { - // Attempt to refresh the token + // Attempt to refresh the token using the stored auth mode + let useCustomClientId = loadUseCustomClientId() do { - let newResult = try await SpotifyAuth.refreshAccessToken(refreshToken: refreshToken) + let newResult = try await SpotifyAuth.refreshAccessToken( + refreshToken: refreshToken, + useCustomClientId: useCustomClientId, + ) // Save the new result to keychain try saveAuthResult(newResult) @@ -114,13 +124,12 @@ enum KeychainManager { /// Saves a custom Spotify Client ID to the keychain nonisolated static func saveCustomClientId(_ clientId: String) throws { - // Delete any existing item first clearCustomClientId() let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.spotifly.config", - kSecAttrAccount as String: "spotify_custom_client_id", + kSecAttrService as String: configService, + kSecAttrAccount as String: customClientIdKey, kSecValueData as String: clientId.data(using: .utf8)!, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, ] @@ -135,8 +144,8 @@ enum KeychainManager { nonisolated static func loadCustomClientId() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.spotifly.config", - kSecAttrAccount as String: "spotify_custom_client_id", + kSecAttrService as String: configService, + kSecAttrAccount as String: customClientIdKey, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] @@ -157,8 +166,60 @@ enum KeychainManager { nonisolated static func clearCustomClientId() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.spotifly.config", - kSecAttrAccount as String: "spotify_custom_client_id", + kSecAttrService as String: configService, + kSecAttrAccount as String: customClientIdKey, + ] + SecItemDelete(query as CFDictionary) + } + + // MARK: - Auth Mode (Custom vs Keymaster) + + /// Saves whether user is using custom client ID mode + nonisolated static func saveUseCustomClientId(_ useCustom: Bool) throws { + clearUseCustomClientId() + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: configService, + kSecAttrAccount as String: useCustomClientIdKey, + kSecValueData as String: (useCustom ? "true" : "false").data(using: .utf8)!, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw KeychainError.saveFailed(status) + } + } + + /// Loads whether user is using custom client ID mode (default: false) + nonisolated static func loadUseCustomClientId() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: configService, + kSecAttrAccount as String: useCustomClientIdKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { + return false + } + return value == "true" + } + + /// Clears the auth mode setting from the keychain + nonisolated static func clearUseCustomClientId() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: configService, + kSecAttrAccount as String: useCustomClientIdKey, ] SecItemDelete(query as CFDictionary) } @@ -171,7 +232,7 @@ enum KeychainManager { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, + kSecAttrService as String: oauthService, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, @@ -187,7 +248,7 @@ enum KeychainManager { private static func load(key: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, + kSecAttrService as String: oauthService, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, @@ -206,7 +267,7 @@ enum KeychainManager { private static func delete(key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, + kSecAttrService as String: oauthService, kSecAttrAccount as String: key, ] diff --git a/Spotifly/SpotifyAuth.swift b/Spotifly/SpotifyAuth.swift index 04d85e5..fa24064 100644 --- a/Spotifly/SpotifyAuth.swift +++ b/Spotifly/SpotifyAuth.swift @@ -2,12 +2,15 @@ // SpotifyAuth.swift // Spotifly // -// Swift implementation of Spotify OAuth using ASWebAuthenticationSession with PKCE +// Dual authentication implementation: +// - Keymaster auth (default): Uses librespot-oauth with official Spotify desktop client ID +// - Custom client ID auth: Uses ASWebAuthenticationSession with user's client ID // import AuthenticationServices import CryptoKit import Foundation +import SpotiflyRust /// Actor that manages Spotify authentication and player operations @globalActor @@ -55,7 +58,7 @@ enum SpotifyAuthError: Error, Sendable, LocalizedError { } } -/// Helper class to manage the auth session and its delegate +/// Helper class to manage the auth session and its delegate (for ASWebAuthenticationSession) private final class AuthenticationSession: NSObject, ASWebAuthenticationPresentationContextProviding, @unchecked Sendable { private let anchor: ASPresentationAnchor @@ -106,16 +109,24 @@ private final class AuthenticationSession: NSObject, ASWebAuthenticationPresenta /// Token response from Spotify API private struct TokenResponse: Decodable, Sendable { - let access_token: String - let refresh_token: String? - let expires_in: Int - let token_type: String + let accessToken: String + let refreshToken: String? + let expiresIn: Int + let tokenType: String let scope: String? + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case tokenType = "token_type" + case scope + } } -/// Swift implementation of Spotify OAuth using ASWebAuthenticationSession with PKCE +/// Swift implementation of dual Spotify OAuth authentication enum SpotifyAuth { - // MARK: - PKCE Helper Functions + // MARK: - PKCE Helper Functions (for ASWebAuthenticationSession) /// Converts data to base64url encoding (RFC 4648) private static func base64URLEncode(_ data: Data) -> String { @@ -155,21 +166,36 @@ enum SpotifyAuth { // MARK: - Public API - /// Initiates the Spotify OAuth flow using ASWebAuthenticationSession. + /// Initiates the Spotify OAuth flow using the appropriate method based on auth mode. + /// - Parameter useCustomClientId: Whether to use custom client ID (ASWebAuthenticationSession) or keymaster (librespot-oauth) /// - Returns: The authentication result containing tokens /// - Throws: SpotifyAuthError if authentication fails @MainActor - static func authenticate() async throws -> SpotifyAuthResult { + static func authenticate(useCustomClientId: Bool) async throws -> SpotifyAuthResult { + if useCustomClientId { + try await authenticateWithASWebAuth() + } else { + try await authenticateWithLibrespot() + } + } + + // MARK: - ASWebAuthenticationSession (Custom Client ID) + + /// Authenticates using ASWebAuthenticationSession with custom client ID + @MainActor + private static func authenticateWithASWebAuth() async throws -> SpotifyAuthResult { let codeVerifier = generateCodeVerifier() let codeChallenge = generateCodeChallenge(from: codeVerifier) let state = generateState() + let clientId = SpotifyConfig.getClientId(useCustomClientId: true) + // Build the authorization URL var components = URLComponents(string: "https://accounts.spotify.com/authorize")! components.queryItems = [ - URLQueryItem(name: "client_id", value: SpotifyConfig.getClientId()), + URLQueryItem(name: "client_id", value: clientId), URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "redirect_uri", value: SpotifyConfig.redirectUri), + URLQueryItem(name: "redirect_uri", value: SpotifyConfig.customRedirectUri), URLQueryItem(name: "scope", value: SpotifyConfig.scopes.joined(separator: " ")), URLQueryItem(name: "state", value: state), URLQueryItem(name: "code_challenge_method", value: "S256"), @@ -189,7 +215,7 @@ enum SpotifyAuth { let authSession = AuthenticationSession(anchor: anchor) let callbackURL = try await authSession.authenticate( url: authURL, - callbackURLScheme: SpotifyConfig.callbackURLScheme, + callbackURLScheme: SpotifyConfig.customCallbackURLScheme, ) // Parse the callback URL to extract the authorization code @@ -216,11 +242,11 @@ enum SpotifyAuth { } // Exchange authorization code for tokens - return try await exchangeCodeForToken(code: code, codeVerifier: codeVerifier) + return try await exchangeCodeForToken(code: code, codeVerifier: codeVerifier, clientId: clientId) } - /// Exchanges an authorization code for access and refresh tokens - private static func exchangeCodeForToken(code: String, codeVerifier: String) async throws -> SpotifyAuthResult { + /// Exchanges an authorization code for access and refresh tokens (for ASWebAuthenticationSession) + private static func exchangeCodeForToken(code: String, codeVerifier: String, clientId: String) async throws -> SpotifyAuthResult { let tokenURL = URL(string: "https://accounts.spotify.com/api/token")! var request = URLRequest(url: tokenURL) @@ -229,8 +255,8 @@ enum SpotifyAuth { request.httpBody = formURLEncode([ "grant_type": "authorization_code", "code": code, - "redirect_uri": SpotifyConfig.redirectUri, - "client_id": SpotifyConfig.getClientId(), + "redirect_uri": SpotifyConfig.customRedirectUri, + "client_id": clientId, "code_verifier": codeVerifier, ]) @@ -246,12 +272,71 @@ enum SpotifyAuth { return try parseTokenResponse(data: data) } + // MARK: - Librespot OAuth (Keymaster) + + /// Authenticates using librespot-oauth with keymaster client ID + @SpotifyAuthActor + private static func authenticateWithLibrespot() async throws -> SpotifyAuthResult { + let clientId = SpotifyConfig.keymasterClientId + let redirectUri = SpotifyConfig.keymasterRedirectUri + + // Run the OAuth flow on a background thread since it blocks + let result = await Task.detached { + spotifly_start_oauth(clientId, redirectUri) + }.value + + guard result == 0 else { + throw SpotifyAuthError.authenticationFailed + } + + guard spotifly_has_oauth_result() == 1 else { + throw SpotifyAuthError.noTokenAvailable + } + + // Get the access token + guard let accessTokenPtr = spotifly_get_access_token() else { + throw SpotifyAuthError.noTokenAvailable + } + let accessToken = String(cString: accessTokenPtr) + spotifly_free_string(accessTokenPtr) + + // Get the refresh token (optional) + var refreshToken: String? + if let refreshTokenPtr = spotifly_get_refresh_token() { + refreshToken = String(cString: refreshTokenPtr) + spotifly_free_string(refreshTokenPtr) + } + + // Get expiration time + let expiresIn = spotifly_get_token_expires_in() + + return SpotifyAuthResult( + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn, + ) + } + + // MARK: - Token Refresh + /// Refreshes the access token using a refresh token. - /// - Parameter refreshToken: The refresh token to use + /// - Parameters: + /// - refreshToken: The refresh token to use + /// - useCustomClientId: Whether to use custom client ID mode /// - Returns: The new authentication result containing fresh tokens /// - Throws: SpotifyAuthError if refresh fails - static func refreshAccessToken(refreshToken: String) async throws -> SpotifyAuthResult { + static func refreshAccessToken(refreshToken: String, useCustomClientId: Bool) async throws -> SpotifyAuthResult { + if useCustomClientId { + try await refreshWithASWebAuth(refreshToken: refreshToken) + } else { + try await refreshWithLibrespot(refreshToken: refreshToken) + } + } + + /// Refreshes token using Spotify API (for ASWebAuthenticationSession/custom client ID) + private static func refreshWithASWebAuth(refreshToken: String) async throws -> SpotifyAuthResult { let tokenURL = URL(string: "https://accounts.spotify.com/api/token")! + let clientId = SpotifyConfig.getClientId(useCustomClientId: true) var request = URLRequest(url: tokenURL) request.httpMethod = "POST" @@ -259,7 +344,7 @@ enum SpotifyAuth { request.httpBody = formURLEncode([ "grant_type": "refresh_token", "refresh_token": refreshToken, - "client_id": SpotifyConfig.getClientId(), + "client_id": clientId, ]) let (data, response) = try await URLSession.shared.data(for: request) @@ -273,19 +358,62 @@ enum SpotifyAuth { return try parseTokenResponse(data: data) } + /// Refreshes token using librespot-oauth (for keymaster) + @SpotifyAuthActor + private static func refreshWithLibrespot(refreshToken: String) async throws -> SpotifyAuthResult { + let clientId = SpotifyConfig.keymasterClientId + let redirectUri = SpotifyConfig.keymasterRedirectUri + + // Run the token refresh on a background thread since it blocks + let result = await Task.detached { + spotifly_refresh_access_token(clientId, redirectUri, refreshToken) + }.value + + guard result == 0 else { + throw SpotifyAuthError.refreshFailed + } + + guard spotifly_has_oauth_result() == 1 else { + throw SpotifyAuthError.noTokenAvailable + } + + // Get the new access token + guard let accessTokenPtr = spotifly_get_access_token() else { + throw SpotifyAuthError.noTokenAvailable + } + let accessToken = String(cString: accessTokenPtr) + spotifly_free_string(accessTokenPtr) + + // Get the new refresh token (optional) + var newRefreshToken: String? + if let refreshTokenPtr = spotifly_get_refresh_token() { + newRefreshToken = String(cString: refreshTokenPtr) + spotifly_free_string(refreshTokenPtr) + } + + // Get expiration time + let expiresIn = spotifly_get_token_expires_in() + + return SpotifyAuthResult( + accessToken: accessToken, + refreshToken: newRefreshToken, + expiresIn: expiresIn, + ) + } + /// Parses the token response from Spotify private static func parseTokenResponse(data: Data) throws -> SpotifyAuthResult { let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) return SpotifyAuthResult( - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, - expiresIn: UInt64(tokenResponse.expires_in), + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiresIn: UInt64(tokenResponse.expiresIn), ) } - /// Clears any stored OAuth result (no-op for this implementation, keychain handles storage) + /// Clears any stored OAuth result (no-op for ASWebAuth, clears Rust state for librespot) static func clearAuthResult() { - // No-op - keychain manager handles clearing + spotifly_clear_oauth_result() } } diff --git a/Spotifly/SpotifyConfig.swift b/Spotifly/SpotifyConfig.swift index e1dee2f..b23e7c9 100644 --- a/Spotifly/SpotifyConfig.swift +++ b/Spotifly/SpotifyConfig.swift @@ -4,8 +4,9 @@ // // Configuration for Spotify API credentials // -// Users must provide their own Spotify Client ID. -// See: https://github.com/ralph/homebrew-spotifly?tab=readme-ov-file#setting-up-your-client-id +// Supports two authentication modes: +// 1. Keymaster auth (default): Uses official Spotify desktop client ID +// 2. Custom client ID auth: Uses user's own client ID from Spotify Developer Dashboard // import Foundation @@ -22,20 +23,44 @@ enum SpotifyConfigError: Error, LocalizedError { } enum SpotifyConfig: Sendable { - /// Returns the Client ID from keychain - /// - Returns: The stored Client ID, or crashes if not set (should be set before login) - nonisolated static func getClientId() -> String { - guard let clientId = KeychainManager.loadCustomClientId(), !clientId.isEmpty else { - fatalError("Missing Spotify Client ID. Please enter your Client ID on the login screen.") + // MARK: - Keymaster Auth Configuration + + /// Keymaster client ID (official Spotify desktop app) + /// This is a well-known public client ID used by the Spotify desktop application + nonisolated static let keymasterClientId = "65b708073fc0480ea92a077233ca87bd" + + /// Redirect URI for keymaster auth (localhost, used by librespot-oauth) + nonisolated static let keymasterRedirectUri = "http://127.0.0.1:8888/login" + + // MARK: - Custom Client ID Auth Configuration + + /// Redirect URI for custom client ID auth (custom URL scheme, used by ASWebAuthenticationSession) + nonisolated static let customRedirectUri = "de.rvdh.spotifly://callback" + + /// URL scheme for the custom callback (extracted from customRedirectUri) + nonisolated static let customCallbackURLScheme = "de.rvdh.spotifly" + + // MARK: - Helper Methods + + /// Returns the Client ID based on auth mode + /// - Parameter useCustomClientId: Whether to use custom client ID mode + /// - Returns: The appropriate client ID for the auth mode + nonisolated static func getClientId(useCustomClientId: Bool) -> String { + if useCustomClientId { + guard let clientId = KeychainManager.loadCustomClientId(), !clientId.isEmpty else { + fatalError("Custom client ID not set. Please enter your Client ID on the login screen.") + } + return clientId } - return clientId + return keymasterClientId } - /// Redirect URI for OAuth callback - nonisolated static let redirectUri = "de.rvdh.spotifly://callback" - - /// URL scheme for the callback (extracted from redirectUri) - nonisolated static let callbackURLScheme = "de.rvdh.spotifly" + /// Returns the redirect URI based on auth mode + /// - Parameter useCustomClientId: Whether to use custom client ID mode + /// - Returns: The appropriate redirect URI for the auth mode + nonisolated static func getRedirectUri(useCustomClientId: Bool) -> String { + useCustomClientId ? customRedirectUri : keymasterRedirectUri + } /// OAuth scopes required by the app nonisolated static let scopes: [String] = [ diff --git a/Spotifly/SpotifySession.swift b/Spotifly/SpotifySession.swift index 9266b6c..ac209da 100644 --- a/Spotifly/SpotifySession.swift +++ b/Spotifly/SpotifySession.swift @@ -88,7 +88,8 @@ final class SpotifySession { isRefreshing = true do { - let newResult = try await SpotifyAuth.refreshAccessToken(refreshToken: refreshToken) + let useCustomClientId = KeychainManager.loadUseCustomClientId() + let newResult = try await SpotifyAuth.refreshAccessToken(refreshToken: refreshToken, useCustomClientId: useCustomClientId) update(with: newResult) try? KeychainManager.saveAuthResult(newResult) #if DEBUG diff --git a/Spotifly/ViewModels/AuthViewModel.swift b/Spotifly/ViewModels/AuthViewModel.swift index 02e2ce0..735d916 100644 --- a/Spotifly/ViewModels/AuthViewModel.swift +++ b/Spotifly/ViewModels/AuthViewModel.swift @@ -37,13 +37,13 @@ final class AuthViewModel { } } - func startOAuth() { + func startOAuth(useCustomClientId: Bool) { isAuthenticating = true errorMessage = nil Task { do { - let result = try await SpotifyAuth.authenticate() + let result = try await SpotifyAuth.authenticate(useCustomClientId: useCustomClientId) self.authResult = result self.isAuthenticating = false diff --git a/Spotifly/Views/ContentView.swift b/Spotifly/Views/ContentView.swift index fe4e589..bf5a2f9 100644 --- a/Spotifly/Views/ContentView.swift +++ b/Spotifly/Views/ContentView.swift @@ -9,7 +9,12 @@ import SwiftUI struct ContentView: View { @State private var viewModel = AuthViewModel() - @State private var clientId: String = KeychainManager.loadCustomClientId() ?? "" + @State private var useCustomClientId = KeychainManager.loadUseCustomClientId() + @State private var clientId = KeychainManager.loadCustomClientId() ?? "" + + private var canConnect: Bool { + !viewModel.isAuthenticating && (!useCustomClientId || !clientId.isEmpty) + } var body: some View { Group { @@ -41,31 +46,38 @@ struct ContentView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) - VStack(alignment: .leading, spacing: 8) { - Text("auth.client_id_label") - .font(.headline) + Toggle("auth.use_custom_client_id", isOn: $useCustomClientId) + .toggleStyle(.checkbox) + .frame(width: 280, alignment: .leading) - TextField("auth.client_id_placeholder", text: $clientId) - .textFieldStyle(.roundedBorder) - .frame(width: 280) + if useCustomClientId { + VStack(alignment: .leading, spacing: 8) { + Text("auth.client_id_label") + .font(.headline) - Link(destination: URL(string: "https://github.com/ralph/homebrew-spotifly?tab=readme-ov-file#setting-up-your-client-id")!) { - Text("auth.client_id_help_link") + TextField("auth.client_id_placeholder", text: $clientId) + .textFieldStyle(.roundedBorder) + .frame(width: 280) + + Link(destination: URL(string: "https://github.com/ralph/homebrew-spotifly?tab=readme-ov-file#setting-up-your-client-id")!) { + Text("auth.client_id_help_link") + .font(.caption) + } + + Text("auth.client_id_existing_app_note") .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 280, alignment: .leading) } - - Text("auth.client_id_existing_app_note") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 280, alignment: .leading) + .frame(width: 280, alignment: .leading) } - .frame(width: 280, alignment: .leading) Button { - if !clientId.isEmpty { + if useCustomClientId, !clientId.isEmpty { try? KeychainManager.saveCustomClientId(clientId) } - viewModel.startOAuth() + try? KeychainManager.saveUseCustomClientId(useCustomClientId) + viewModel.startOAuth(useCustomClientId: useCustomClientId) } label: { HStack { if viewModel.isAuthenticating { @@ -79,7 +91,7 @@ struct ContentView: View { } .buttonStyle(.borderedProminent) .tint(.green) - .disabled(viewModel.isAuthenticating || clientId.isEmpty) + .disabled(!canConnect) if let error = viewModel.errorMessage { Text(error) diff --git a/Spotifly/de.lproj/Localizable.strings b/Spotifly/de.lproj/Localizable.strings index 5d8e16e..af8702f 100644 --- a/Spotifly/de.lproj/Localizable.strings +++ b/Spotifly/de.lproj/Localizable.strings @@ -11,6 +11,7 @@ "auth.client_id_placeholder" = "Client ID eingeben"; "auth.client_id_help_link" = "Wie bekomme ich eine Client ID?"; "auth.client_id_existing_app_note" = "Du kannst eine vorhandene Spotify-App verwenden, wenn du die Redirect-URI de.rvdh.spotifly://callback hinzufügst"; +"auth.use_custom_client_id" = "Eigene Client ID verwenden"; /* Navigation */ "nav.startpage" = "Startseite"; diff --git a/Spotifly/en.lproj/Localizable.strings b/Spotifly/en.lproj/Localizable.strings index 336d4fe..c02b047 100644 --- a/Spotifly/en.lproj/Localizable.strings +++ b/Spotifly/en.lproj/Localizable.strings @@ -11,6 +11,7 @@ "auth.client_id_placeholder" = "Enter your Client ID"; "auth.client_id_help_link" = "How do I get a Client ID?"; "auth.client_id_existing_app_note" = "You can use an existing Spotify app if you add the redirect URI de.rvdh.spotifly://callback"; +"auth.use_custom_client_id" = "Use custom Client ID"; /* Navigation */ "nav.startpage" = "Startpage"; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e52584d..56e91c9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2332,6 +2332,7 @@ version = "0.1.0" dependencies = [ "librespot-core", "librespot-metadata", + "librespot-oauth", "librespot-playback", "once_cell", "serde", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index ab6d078..137333c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["staticlib"] [dependencies] librespot-core = "0.8" librespot-metadata = "0.8" +librespot-oauth = "0.8" librespot-playback = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } once_cell = "1.19" diff --git a/rust/include/spotifly_rust.h b/rust/include/spotifly_rust.h index a08fb7c..f7c1315 100644 --- a/rust/include/spotifly_rust.h +++ b/rust/include/spotifly_rust.h @@ -2,6 +2,7 @@ #define SPOTIFLY_RUST_H #include +#include #include #ifdef __cplusplus @@ -11,6 +12,45 @@ extern "C" { /// Frees a C string allocated by this library. void spotifly_free_string(char* s); +// ============================================================================ +// OAuth functions +// ============================================================================ + +/// Initiates the Spotify OAuth flow. Opens the browser for user authentication. +/// Returns 0 on success, -1 on error. +/// After successful authentication, use spotifly_get_access_token() to retrieve the token. +/// +/// @param client_id Spotify API client ID as a C string +/// @param redirect_uri OAuth redirect URI as a C string +int32_t spotifly_start_oauth(const char* client_id, const char* redirect_uri); + +/// Returns the access token as a C string. Caller must free the string with spotifly_free_string(). +/// Returns NULL if no token is available. +char* spotifly_get_access_token(void); + +/// Returns the refresh token as a C string. Caller must free the string with spotifly_free_string(). +/// Returns NULL if no refresh token is available. +char* spotifly_get_refresh_token(void); + +/// Returns the token expiration time in seconds. +/// Returns 0 if no token is available. +uint64_t spotifly_get_token_expires_in(void); + +/// Checks if an OAuth result is available. +/// Returns 1 if available, 0 otherwise. +int32_t spotifly_has_oauth_result(void); + +/// Clears the stored OAuth result. +void spotifly_clear_oauth_result(void); + +/// Refreshes the access token using a refresh token. +/// Returns 0 on success, -1 on error. +/// +/// @param client_id Spotify API client ID as a C string +/// @param redirect_uri OAuth redirect URI as a C string +/// @param refresh_token The refresh token as a C string +int32_t spotifly_refresh_access_token(const char* client_id, const char* redirect_uri, const char* refresh_token); + // ============================================================================ // Playback functions // ============================================================================ diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f3d313a..c9caa17 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,6 +3,7 @@ use librespot_core::SessionConfig; use librespot_core::cache::Cache; use librespot_core::SpotifyUri; use librespot_metadata::{Album, Artist, Metadata, Playlist, Track}; +use librespot_oauth::{OAuthClientBuilder, OAuthError}; use librespot_playback::audio_backend; use librespot_playback::config::{AudioFormat, Bitrate, PlayerConfig}; use librespot_playback::mixer::softmixer::SoftMixer; @@ -13,7 +14,7 @@ use std::ffi::{c_char, CStr, CString}; use std::ptr; use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::runtime::Runtime; use tokio::sync::mpsc; @@ -25,6 +26,36 @@ static RUNTIME: Lazy = Lazy::new(|| { .expect("Failed to create Tokio runtime") }); +// Thread-safe storage for OAuth result +static OAUTH_RESULT: Lazy>> = Lazy::new(|| Mutex::new(None)); + +// OAuth scopes - centralized to avoid duplication +const OAUTH_SCOPES: &[&str] = &[ + "user-read-private", + "user-read-email", + "streaming", + "user-read-playback-state", + "user-modify-playback-state", + "user-read-currently-playing", + "playlist-read-private", + "playlist-read-collaborative", + "playlist-modify-public", + "playlist-modify-private", + "user-library-read", + "user-library-modify", + "user-follow-read", + "user-read-recently-played", + "user-top-read", +]; + +struct OAuthResult { + access_token: String, + refresh_token: Option, + expires_in: u64, + #[allow(dead_code)] + scopes: Vec, +} + // Player state static PLAYER: Lazy>>> = Lazy::new(|| Mutex::new(None)); static SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); @@ -1534,3 +1565,241 @@ pub extern "C" fn spotifly_set_gapless(enabled: bool) { pub extern "C" fn spotifly_get_gapless() -> bool { GAPLESS_SETTING.load(Ordering::SeqCst) } + +// ============================================================================ +// OAuth functions +// ============================================================================ + +/// Initiates the Spotify OAuth flow. Opens the browser for user authentication. +/// Returns 0 on success, -1 on error. +/// After successful authentication, use spotifly_get_access_token() to retrieve the token. +/// +/// # Parameters +/// - client_id: Spotify API client ID as a C string +/// - redirect_uri: OAuth redirect URI as a C string +#[no_mangle] +pub extern "C" fn spotifly_start_oauth(client_id: *const c_char, redirect_uri: *const c_char) -> i32 { + // Validate and convert C strings to Rust strings + if client_id.is_null() || redirect_uri.is_null() { + eprintln!("OAuth error: client_id or redirect_uri is null"); + return -1; + } + + let client_id_str = unsafe { + match CStr::from_ptr(client_id).to_str() { + Ok(s) => s.to_string(), + Err(_) => { + eprintln!("OAuth error: invalid client_id string"); + return -1; + } + } + }; + + let redirect_uri_str = unsafe { + match CStr::from_ptr(redirect_uri).to_str() { + Ok(s) => s.to_string(), + Err(_) => { + eprintln!("OAuth error: invalid redirect_uri string"); + return -1; + } + } + }; + + let result = RUNTIME.block_on(async { + perform_oauth(&client_id_str, &redirect_uri_str).await + }); + + match result { + Ok(oauth_result) => { + let mut guard = OAUTH_RESULT.lock().unwrap(); + *guard = Some(oauth_result); + 0 + } + Err(e) => { + eprintln!("OAuth error: {:?}", e); + -1 + } + } +} + +async fn perform_oauth(client_id: &str, redirect_uri: &str) -> Result { + let scopes = OAUTH_SCOPES.to_vec(); + + // Load HTML from external file at compile time + let success_message = include_str!("oauth_success.html"); + + let client = OAuthClientBuilder::new(client_id, redirect_uri, scopes) + .open_in_browser() + .with_custom_message(success_message) + .build()?; + + let token = client.get_access_token()?; + + let now = Instant::now(); + let expires_in_secs = if token.expires_at > now { + token.expires_at.duration_since(now).as_secs() + } else { + 0 + }; + + Ok(OAuthResult { + access_token: token.access_token, + refresh_token: Some(token.refresh_token), + expires_in: expires_in_secs, + scopes: token.scopes, + }) +} + +/// Returns the access token as a C string. Caller must free the string with spotifly_free_string(). +/// Returns NULL if no token is available. +#[no_mangle] +pub extern "C" fn spotifly_get_access_token() -> *mut c_char { + let guard = OAUTH_RESULT.lock().unwrap(); + match guard.as_ref() { + Some(result) => { + match CString::new(result.access_token.clone()) { + Ok(cstr) => cstr.into_raw(), + Err(_) => ptr::null_mut(), + } + } + None => ptr::null_mut(), + } +} + +/// Returns the refresh token as a C string. Caller must free the string with spotifly_free_string(). +/// Returns NULL if no refresh token is available. +#[no_mangle] +pub extern "C" fn spotifly_get_refresh_token() -> *mut c_char { + let guard = OAUTH_RESULT.lock().unwrap(); + match guard.as_ref() { + Some(result) => { + match result.refresh_token.as_ref() { + Some(refresh_token) => { + match CString::new(refresh_token.clone()) { + Ok(cstr) => cstr.into_raw(), + Err(_) => ptr::null_mut(), + } + } + None => ptr::null_mut(), + } + } + None => ptr::null_mut(), + } +} + +/// Returns the token expiration time in seconds. +/// Returns 0 if no token is available. +#[no_mangle] +pub extern "C" fn spotifly_get_token_expires_in() -> u64 { + let guard = OAUTH_RESULT.lock().unwrap(); + match guard.as_ref() { + Some(result) => result.expires_in, + None => 0, + } +} + +/// Checks if an OAuth result is available. +/// Returns 1 if available, 0 otherwise. +#[no_mangle] +pub extern "C" fn spotifly_has_oauth_result() -> i32 { + let guard = OAUTH_RESULT.lock().unwrap(); + if guard.is_some() { 1 } else { 0 } +} + +/// Clears the stored OAuth result. +#[no_mangle] +pub extern "C" fn spotifly_clear_oauth_result() { + let mut guard = OAUTH_RESULT.lock().unwrap(); + *guard = None; +} + +/// Refreshes the access token using a refresh token. +/// Returns 0 on success, -1 on error. +/// +/// # Parameters +/// - client_id: Spotify API client ID as a C string +/// - redirect_uri: OAuth redirect URI as a C string +/// - refresh_token: The refresh token as a C string +#[no_mangle] +pub extern "C" fn spotifly_refresh_access_token( + client_id: *const c_char, + redirect_uri: *const c_char, + refresh_token: *const c_char, +) -> i32 { + if client_id.is_null() || redirect_uri.is_null() || refresh_token.is_null() { + eprintln!("Refresh error: client_id, redirect_uri, or refresh_token is null"); + return -1; + } + + let client_id_str = unsafe { + match CStr::from_ptr(client_id).to_str() { + Ok(s) => s.to_string(), + Err(_) => { + eprintln!("Refresh error: invalid client_id string"); + return -1; + } + } + }; + + let redirect_uri_str = unsafe { + match CStr::from_ptr(redirect_uri).to_str() { + Ok(s) => s.to_string(), + Err(_) => { + eprintln!("Refresh error: invalid redirect_uri string"); + return -1; + } + } + }; + + let refresh_token_str = unsafe { + match CStr::from_ptr(refresh_token).to_str() { + Ok(s) => s.to_string(), + Err(_) => { + eprintln!("Refresh error: invalid refresh_token string"); + return -1; + } + } + }; + + let result = RUNTIME.block_on(async { + perform_token_refresh(&client_id_str, &redirect_uri_str, &refresh_token_str).await + }); + + match result { + Ok(oauth_result) => { + let mut guard = OAUTH_RESULT.lock().unwrap(); + *guard = Some(oauth_result); + 0 + } + Err(e) => { + eprintln!("Refresh error: {:?}", e); + -1 + } + } +} + +async fn perform_token_refresh( + client_id: &str, + redirect_uri: &str, + refresh_token: &str, +) -> Result { + let scopes = OAUTH_SCOPES.to_vec(); + + let client = OAuthClientBuilder::new(client_id, redirect_uri, scopes).build()?; + + let token = client.refresh_token(refresh_token)?; + + let now = Instant::now(); + let expires_in_secs = if token.expires_at > now { + token.expires_at.duration_since(now).as_secs() + } else { + 0 + }; + + Ok(OAuthResult { + access_token: token.access_token, + refresh_token: Some(token.refresh_token), + expires_in: expires_in_secs, + scopes: token.scopes, + }) +} diff --git a/rust/src/oauth_success.html b/rust/src/oauth_success.html new file mode 100644 index 0000000..197a791 --- /dev/null +++ b/rust/src/oauth_success.html @@ -0,0 +1,36 @@ + + + + + Spotifly - Authentication Successful + + + +
+
+

Authentication Successful

+

Please go back to the Spotifly window.

+
+ +