diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 1ae920a..4ff50b0 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 50C912892F5DD9280087EE61 /* AuthMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C912872F5DD9280087EE61 /* AuthMode.swift */; }; + 50C912912F5DD9990087EE61 /* IAPLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C9128A2F5DD9990087EE61 /* IAPLoginView.swift */; }; + 50C912A42F648A440087EE61 /* IAPWebAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C912A32F648A440087EE61 /* IAPWebAuthView.swift */; }; + 50C912A62F648A490087EE61 /* IAPWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50C912A52F648A490087EE61 /* IAPWebView.swift */; }; B02A003F2F36662C0024E8EC /* UIScreen+.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02A003E2F3666240024E8EC /* UIScreen+.swift */; }; B0AD2E712F4B037400577062 /* ArtistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E6E2F4B037400577062 /* ArtistDetailView.swift */; }; B0AD2E722F4B037400577062 /* ArtistsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AD2E6F2F4B037400577062 /* ArtistsView.swift */; }; @@ -147,6 +151,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 50C912872F5DD9280087EE61 /* AuthMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthMode.swift; sourceTree = ""; }; + 50C9128A2F5DD9990087EE61 /* IAPLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPLoginView.swift; sourceTree = ""; }; + 50C912A32F648A440087EE61 /* IAPWebAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPWebAuthView.swift; sourceTree = ""; }; + 50C912A52F648A490087EE61 /* IAPWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPWebView.swift; sourceTree = ""; }; B02A003E2F3666240024E8EC /* UIScreen+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+.swift"; sourceTree = ""; }; B0AD2E6E2F4B037400577062 /* ArtistDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistDetailView.swift; sourceTree = ""; }; B0AD2E6F2F4B037400577062 /* ArtistsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistsView.swift; sourceTree = ""; }; @@ -320,6 +328,8 @@ C4289F4D2C1253EB00C3A4FD /* Utils */ = { isa = PBXGroup; children = ( + 50C912A52F648A490087EE61 /* IAPWebView.swift */, + 50C912A32F648A440087EE61 /* IAPWebAuthView.swift */, B02A003E2F3666240024E8EC /* UIScreen+.swift */, C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */, @@ -327,6 +337,7 @@ C415F5632C11AA8700E3E1D2 /* Fonts.swift */, C49134522C15BE0C00CCF2EB /* Strings.swift */, C4120FD82C15D58E00E712BE /* Errors.swift */, + 50C9128A2F5DD9990087EE61 /* IAPLoginView.swift */, ); path = Utils; sourceTree = ""; @@ -457,6 +468,7 @@ C4DFFA202D32E769003B9C4E /* DownloadViewModel.swift */, C456D8FD2F300D37002AAB8B /* LyricsView.swift */, C42B25662F44533D00E62008 /* Watch */, + 50C912872F5DD9280087EE61 /* AuthMode.swift */, ); path = flo; sourceTree = ""; @@ -633,6 +645,7 @@ C49495812C1C25E5006B4D1E /* ScanStatus.swift in Sources */, C4289F482C12391300C3A4FD /* AlbumViewModel.swift in Sources */, C42B25882F4456BF00E62008 /* WatchLibraryResponder.swift in Sources */, + 50C912A62F648A490087EE61 /* IAPWebView.swift in Sources */, C49495852C1C26D4006B4D1E /* ScanStatusService.swift in Sources */, C47876042C2BFFF900184A33 /* SongView.swift in Sources */, C446A6B72C08DE8800CC9787 /* UserAuth.swift in Sources */, @@ -648,8 +661,10 @@ C4120FDD2C15E1C300E712BE /* Song.swift in Sources */, C467AD532D3267D000644E68 /* Subsonic.swift in Sources */, C47876022C2BF15900184A33 /* AlbumsView.swift in Sources */, + 50C912A42F648A440087EE61 /* IAPWebAuthView.swift in Sources */, C4824D272CE908DC003EAB52 /* SongsView.swift in Sources */, C456D8FA2F2FF33E002AAB8B /* LRCParser.swift in Sources */, + 50C912892F5DD9280087EE61 /* AuthMode.swift in Sources */, C4F870CE2CEFCC5E00312F8A /* FloooService.swift in Sources */, C4DFFA212D32E76E003B9C4E /* DownloadViewModel.swift in Sources */, C4F1A0012F4A000100AAAAAA /* InAppPurchaseManager.swift in Sources */, @@ -677,6 +692,7 @@ C4A4BF3D2C1455A100363290 /* FloatingPlayerView.swift in Sources */, C415F54E2C11908100E3E1D2 /* AuthViewModel.swift in Sources */, C42B25842F44551B00E62008 /* PlaybackCoordinator.swift in Sources */, + 50C912912F5DD9990087EE61 /* IAPLoginView.swift in Sources */, C4A4BF372C14442F00363290 /* DownloadsView.swift in Sources */, C4100A692CE78B25001BC9BE /* PlaylistView.swift in Sources */, C440228D2C09BE2E004EE9CD /* PlayerView.swift in Sources */, diff --git a/flo/AuthMode.swift b/flo/AuthMode.swift new file mode 100644 index 0000000..c15caad --- /dev/null +++ b/flo/AuthMode.swift @@ -0,0 +1,25 @@ +// +// AuthMode.swift +// flo +// +// Created by piekay on 08/03/26. +// + +import Foundation + +enum AuthMode: String, Codable { + case standard + case iap +} + +struct IAPAuthInfo: Codable { + let jwtAssertion: String + let userEmail: String? + let userId: String? + + init(jwtAssertion: String, userEmail: String? = nil, userId: String? = nil) { + self.jwtAssertion = jwtAssertion + self.userEmail = userEmail + self.userId = userId + } +} diff --git a/flo/AuthViewModel.swift b/flo/AuthViewModel.swift index cc33297..048c548 100644 --- a/flo/AuthViewModel.swift +++ b/flo/AuthViewModel.swift @@ -27,6 +27,10 @@ class AuthViewModel: ObservableObject { @Published var isSubmitting: Bool = false @Published var isLoggedIn: Bool = false + + @Published var authMode: AuthMode = .standard + @Published var iapJwtAssertion: String = "" + @Published var useIAPAuth: Bool = false static let shared = AuthViewModel() @@ -49,6 +53,8 @@ class AuthViewModel: ObservableObject { self.serverUrl = UserDefaultsManager.serverBaseURL self.username = data.username + + self.authMode = AuthService.shared.getAuthMode() if UserDefaultsManager.saveLoginInfo { do { @@ -57,7 +63,11 @@ class AuthViewModel: ObservableObject { print("Error loading password from Keychain: \(error)") } - self.login() + if authMode == .iap, let iapInfo = AuthService.shared.getIAPAuthInfo() { + self.loginWithIAP(jwtAssertion: iapInfo.jwtAssertion) + } else { + self.login() + } } else { self.user = UserAuth( @@ -123,11 +133,18 @@ class AuthViewModel: ObservableObject { try KeychainManager.removeAuthCreds() self.destroySavedPassword() + + if authMode == .iap { + try? KeychainManager.removeIAPAuthInfo() + try? KeychainManager.removeAuthMode() + AuthService.shared.clearIAPAuthInfo() + } UserDefaultsManager.removeObject(key: UserDefaultsKeys.serverURL) self.user = nil self.isLoggedIn = false + self.authMode = .standard } catch let error { print("error>>>>> \(error)") } @@ -161,4 +178,63 @@ class AuthViewModel: ObservableObject { print("Error saving data to Keychain: \(error)") } } + + func loginWithIAP(jwtAssertion: String? = nil) { + isSubmitting = true + + let jwt = jwtAssertion ?? self.iapJwtAssertion + + guard !jwt.isEmpty else { + DispatchQueue.main.async { + self.isSubmitting = false + self.alertMessage = "JWT assertion is required for IAP authentication" + self.showAlert = true + } + return + } + + AuthService.shared.loginWithIAP(serverUrl: serverUrl, jwtAssertion: jwt) { result in + switch result { + case .success(let data): + self.persistAuthData(data) + + self.authMode = .iap + + if UserDefaultsManager.saveLoginInfo { + self.destroySavedPassword() + } + + DispatchQueue.main.async { + self.isSubmitting = false + self.isLoggedIn = true + self.iapJwtAssertion = "" + self.serverUrl = "" + } + + case .failure(let error): + DispatchQueue.main.async { + self.isSubmitting = false + + switch error { + case .server(let message): + self.alertMessage = message + + case .unknown: + self.alertMessage = "Unknown error occurred during IAP authentication" + } + + self.showAlert = true + } + } + } + } + + func toggleAuthMode() { + useIAPAuth.toggle() + } + + func isUsingIAPAuth() -> Bool { + return authMode == .iap + } } + diff --git a/flo/Info.plist b/flo/Info.plist index 9899ea6..0e74e0a 100644 --- a/flo/Info.plist +++ b/flo/Info.plist @@ -2,8 +2,8 @@ - ITSAppUsesNonExemptEncryption - + ITSAppUsesNonExemptEncryption + UIAppFonts PlusJakartaSans-VariableFont_wght.ttf diff --git a/flo/LoginView.swift b/flo/LoginView.swift index 56c5839..ed34a0e 100644 --- a/flo/LoginView.swift +++ b/flo/LoginView.swift @@ -10,6 +10,8 @@ import SwiftUI struct Login: View { @ObservedObject var viewModel: AuthViewModel @Binding var showLoginSheet: Bool + + @State private var showIAPLogin = false var isSubmitButtonDisabled: Bool { viewModel.serverUrl.isEmpty || viewModel.username.isEmpty || viewModel.password.isEmpty @@ -31,6 +33,15 @@ struct Login: View { dismissButton: .default(Text("OK")) ) } + .sheet(isPresented: $showIAPLogin) { + IAPLoginView(authViewModel: viewModel) + } + .onChange(of: viewModel.isLoggedIn) { isLoggedIn in + if isLoggedIn { + showLoginSheet = false + showIAPLogin = false + } + } .background(Color(.systemBackground)) .foregroundColor(.accent) } @@ -90,6 +101,7 @@ struct Login: View { formField(title: "Username", text: $viewModel.username, placeholder: "sigma") secureFormField(title: "Password", text: $viewModel.password, placeholder: "*************") submitButton + iapLoginButton } .padding(.bottom, 30) .padding(.horizontal, 10) @@ -154,6 +166,45 @@ struct Login: View { .disabled(isSubmitButtonDisabled) } } + + private var iapLoginButton: some View { + VStack(spacing: 12) { + HStack { + VStack { Divider() } + Text("OR") + .customFont(.caption1) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + VStack { Divider() } + } + .padding(.horizontal, 15) + .padding(.vertical, 10) + + Button(action: { showIAPLogin = true }) { + HStack { + Image(systemName: "lock.shield.fill") + .font(.system(size: 16)) + Text("Login with IAP") + .fontWeight(.semibold) + .customFont(.headline) + } + .foregroundColor(Color("PlayerColor")) + .padding() + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 5) + .stroke(Color("PlayerColor"), lineWidth: 2) + ) + } + .padding(.horizontal, 15) + + Text("Use this if your server is behind OAuth2-Proxy or Identity-Aware Proxy") + .customFont(.caption1) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } } struct LoginView_Previews: PreviewProvider { diff --git a/flo/Navigation/PreferencesView.swift b/flo/Navigation/PreferencesView.swift index 96d6c05..27438d1 100644 --- a/flo/Navigation/PreferencesView.swift +++ b/flo/Navigation/PreferencesView.swift @@ -378,9 +378,12 @@ struct PreferencesView: View { } } )) + .disabled(authViewModel.authMode == .iap) Text( - "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option)" + authViewModel.authMode == .iap + ? "This option is not available when using OAuth." + : "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option). Logging in via OAuth will reset this option." ).font(.caption).foregroundColor(.gray) } .sheet(isPresented: shouldShowLoginSheet) { diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 66944af..676056c 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -211,6 +211,10 @@ } } }, + "Advanced Settings" : { + "comment" : "A toggle label for advanced settings in the IAP login view.", + "isCommentAutoGenerated" : true + }, "Album Artist Only" : { "localizations" : { "id" : { @@ -299,6 +303,30 @@ } } }, + "Authenticate" : { + "comment" : "The text of the button that allows a user to authenticate with their in-app purchases.", + "isCommentAutoGenerated" : true + }, + "Authenticate using OAuth2-Proxy or Identity-Aware Proxy" : { + "comment" : "A description below the IAP login form, explaining how to authenticate using an IAP server.", + "isCommentAutoGenerated" : true + }, + "Authenticating..." : { + "comment" : "A label displayed while waiting for the IAP authentication to complete.", + "isCommentAutoGenerated" : true + }, + "Authentication Failed" : { + "comment" : "A title that appears when IAP authentication fails.", + "isCommentAutoGenerated" : true + }, + "Authentication Token Cookie Name" : { + "comment" : "A description of the authentication token cookie name setting.", + "isCommentAutoGenerated" : true + }, + "Authentication Token Header Name" : { + "comment" : "A label displayed above a text field that lets the user specify the name of the HTTP header containing their JWT token.", + "isCommentAutoGenerated" : true + }, "Bring your music anywhere, even when you're offline. Your downloaded music will be here." : { "localizations" : { "en" : { @@ -537,6 +565,18 @@ } } }, + "e.g., _oauth2_proxy, KEYCLOAK_IDENTITY" : { + "comment" : "A placeholder text for the \"Authentication Token Cookie Name\" field in the IAP login view.", + "isCommentAutoGenerated" : true + }, + "e.g., username, user, preferred_username" : { + "comment" : "A placeholder text for the \"Username Cookie Name\" field in the advanced settings of the in-app purchase login view.", + "isCommentAutoGenerated" : true + }, + "e.g., x-auth-request-access-token" : { + "comment" : "A placeholder text for the authentication token header name field in the advanced settings of the in-app purchase login view.", + "isCommentAutoGenerated" : true + }, "Enable Debug" : { "localizations" : { "id" : { @@ -573,7 +613,7 @@ } } }, - "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option)" : { + "flo will store your server URL, username, and password in the Keychain with no biometric protection. If you enable this, flo will try to 'refresh' the auth token—by logging you in automatically—every time you open flo so you'll never log out unless you do it explicitly (it will also reset this option). Logging in via OAuth will reset this option." : { "localizations" : { "id" : { "stringUnit" : { @@ -872,6 +912,10 @@ } } }, + "Login with IAP" : { + "comment" : "A button label that allows users to log in using in-app purchases.", + "isCommentAutoGenerated" : true + }, "Logout" : { "localizations" : { "en" : { @@ -1028,6 +1072,10 @@ } } }, + "OR" : { + "comment" : "A label used to separate different options in a list.", + "isCommentAutoGenerated" : true + }, "Play" : { "localizations" : { "en" : { @@ -1413,6 +1461,10 @@ } } }, + "Sign In" : { + "comment" : "The title of the navigation bar at the top of the view.", + "isCommentAutoGenerated" : true + }, "Some other things yet to come" : { "localizations" : { "id" : { @@ -1498,6 +1550,14 @@ } } }, + "The cookie containing your session token (leave empty for auto-detection)" : { + "comment" : "A description of the purpose of the \"Authentication Token Cookie Name\" field.", + "isCommentAutoGenerated" : true + }, + "The cookie containing your username (defaults to 'username')" : { + "comment" : "A description of the purpose of the \"Username Cookie Name\" setting in the IAP login view.", + "isCommentAutoGenerated" : true + }, "The full version of flo is always Free and OSS" : { "localizations" : { "id" : { @@ -1508,6 +1568,10 @@ } } }, + "The HTTP header containing your JWT token (leave empty for auto-detection)" : { + "comment" : "A description of what the \"Authentication Token Header Name\" field is for.", + "isCommentAutoGenerated" : true + }, "The password is stored securely in Keychain" : { "localizations" : { "en" : { @@ -1539,6 +1603,9 @@ } } } + }, + "This option is not available when using OAuth." : { + }, "This stat is generated on-device (once every session) and no data is stored or shared with a third party — #selfhosting, baby!" : { "localizations" : { @@ -1634,6 +1701,10 @@ } } }, + "Try Again" : { + "comment" : "A button that allows the user to try authenticating again if the previous attempt failed.", + "isCommentAutoGenerated" : true + }, "Unable to Change App Icon" : { "localizations" : { "id" : { @@ -1654,6 +1725,10 @@ } } }, + "Use this if your server is behind OAuth2-Proxy or Identity-Aware Proxy" : { + "comment" : "A piece of text explaining the purpose of the IAP login button.", + "isCommentAutoGenerated" : true + }, "UserDefaults.%@" : { "localizations" : { "id" : { @@ -1664,6 +1739,10 @@ } } }, + "Username Cookie Name" : { + "comment" : "A label for the username cookie name field in the IAP login view.", + "isCommentAutoGenerated" : true + }, "Your Navidrome session may have expired" : { "localizations" : { "en" : { @@ -1681,5 +1760,5 @@ } } }, - "version" : "1.0" -} \ No newline at end of file + "version" : "1.1" +} diff --git a/flo/Shared/Services/APIManager.swift b/flo/Shared/Services/APIManager.swift index 5ec2f4a..de5d9fe 100644 --- a/flo/Shared/Services/APIManager.swift +++ b/flo/Shared/Services/APIManager.swift @@ -172,6 +172,30 @@ extension APIManager { completion(response) } } + + func loginWithIAP( + endpoint: String, parameters: Parameters?, jwtAssertion: String, + completion: @escaping (DataResponse) -> Void + ) { + let headers: HTTPHeaders = [ + "X-Goog-IAP-JWT-Assertion": jwtAssertion + ] + + session.request( + endpoint, + method: .post, + parameters: parameters, + encoding: JSONEncoding.default, + headers: headers, + requestModifier: { request in + request.timeoutInterval = 10 + } + ) + .validate(statusCode: 200..<500) + .responseDecodable(of: T.self) { response in + completion(response) + } + } func externalRequest( url: String, diff --git a/flo/Shared/Services/AuthService.swift b/flo/Shared/Services/AuthService.swift index c0512bb..3bc21cc 100644 --- a/flo/Shared/Services/AuthService.swift +++ b/flo/Shared/Services/AuthService.swift @@ -14,6 +14,8 @@ class AuthService { private var NDToken: String? private var subsonicParams: String? + private var authMode: AuthMode = .standard + private var iapAuthInfo: IAPAuthInfo? private init() { if let jsonString = try? KeychainManager.getAuthCreds(), @@ -25,6 +27,14 @@ class AuthService { "?u=\(data.username)&t=\(data.subsonicToken)&s=\(data.subsonicSalt)&v=\(AppMeta.subsonicApiVersion)&c=\(AppMeta.name)&f=json" } } + + if let mode = try? KeychainManager.getAuthMode() { + authMode = mode + } + + if authMode == .iap { + iapAuthInfo = try? KeychainManager.getIAPAuthInfo() + } } func getCreds(key: String = "") -> String { @@ -39,9 +49,23 @@ class AuthService { return token } } + + if key == "IAPJwt" { + if let jwt = iapAuthInfo?.jwtAssertion { + return jwt + } + } return "" } + + func getAuthMode() -> AuthMode { + return authMode + } + + func getIAPAuthInfo() -> IAPAuthInfo? { + return iapAuthInfo + } func setCreds(_ data: UserAuth) { let subsonicParams = @@ -50,6 +74,21 @@ class AuthService { self.NDToken = data.token self.subsonicParams = subsonicParams } + + func setAuthMode(_ mode: AuthMode) { + self.authMode = mode + try? KeychainManager.setAuthMode(mode) + } + + func setIAPAuthInfo(_ info: IAPAuthInfo) { + self.iapAuthInfo = info + try? KeychainManager.setIAPAuthInfo(info) + } + + func clearIAPAuthInfo() { + self.iapAuthInfo = nil + try? KeychainManager.removeIAPAuthInfo() + } func login( serverUrl: String, username: String, password: String, @@ -66,6 +105,8 @@ class AuthService { (response: DataResponse) in switch response.result { case .success(let authResponse): + // Set auth mode to standard for username/password login + self.setAuthMode(.standard) completion(.success(authResponse)) case .failure(let afError): ErrorHandler.handleFailure(afError, response: response) { result in @@ -87,4 +128,84 @@ class AuthService { } } } + + func loginWithIAP( + serverUrl: String, + jwtAssertion: String, + completion: @escaping (AuthResult) -> Void + ) { + let serverBaseUrl = UserDefaultsManager.serverBaseURL + let isServerBaseURLExist = serverBaseUrl != "" + + let url = "\(isServerBaseURLExist ? serverBaseUrl : serverUrl)\(API.NDEndpoint.loginIAP ?? "/auth/iap")" + + let parameters: [String: Any] = ["jwt": jwtAssertion] + + APIManager.shared.loginWithIAP(endpoint: url, parameters: parameters, jwtAssertion: jwtAssertion) { + (response: DataResponse) in + switch response.result { + case .success(let authResponse): + let userEmail = self.extractEmailFromJWT(jwtAssertion) + let userId = self.extractUserIdFromJWT(jwtAssertion) + + let iapInfo = IAPAuthInfo( + jwtAssertion: jwtAssertion, + userEmail: userEmail, + userId: userId + ) + + self.setAuthMode(.iap) + self.setIAPAuthInfo(iapInfo) + + completion(.success(authResponse)) + + case .failure(let afError): + ErrorHandler.handleFailure(afError, response: response) { result in + LoggerStore.shared.storeMessage( + label: "AuthService.loginWithIAP", + level: .debug, + message: response.debugDescription + ) + completion(AuthResult(result: result)) + } + } + } + } + + private func extractEmailFromJWT(_ jwt: String) -> String? { + guard let payload = decodeJWTPayload(jwt), + let email = payload["email"] as? String else { + return nil + } + return email + } + + private func extractUserIdFromJWT(_ jwt: String) -> String? { + guard let payload = decodeJWTPayload(jwt), + let userId = payload["sub"] as? String else { + return nil + } + return userId + } + + private func decodeJWTPayload(_ jwt: String) -> [String: Any]? { + let segments = jwt.components(separatedBy: ".") + guard segments.count > 1 else { return nil } + + let payloadSegment = segments[1] + + var base64 = payloadSegment + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let paddingLength = (4 - base64.count % 4) % 4 + base64 += String(repeating: "=", count: paddingLength) + + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + return json + } } diff --git a/flo/Shared/Services/KeychainManager.swift b/flo/Shared/Services/KeychainManager.swift index 2505eed..eac87ed 100644 --- a/flo/Shared/Services/KeychainManager.swift +++ b/flo/Shared/Services/KeychainManager.swift @@ -10,6 +10,9 @@ import KeychainAccess class KeychainManager { private static let keychain = Keychain(service: KeychainKeys.service) + + private static let iapAuthInfoKey = "iapAuthInfo" + private static let authModeKey = "authMode" static func getAuthCredsAndPasswords() -> [String: Any] { var keychainData: [String: Any] = [:] @@ -33,6 +36,16 @@ class KeychainManager { } catch { keychainData["authPassword"] = "Error: \(error.localizedDescription)" } + + do { + if let authMode = try getAuthMode() { + keychainData["authMode"] = authMode.rawValue + } else { + keychainData["authMode"] = "nil" + } + } catch { + keychainData["authMode"] = "Error: \(error.localizedDescription)" + } return keychainData } @@ -60,4 +73,40 @@ class KeychainManager { static func setAuthPassword(newValue: String) throws { try keychain.set(newValue, key: KeychainKeys.serverPassword) } + + static func getIAPAuthInfo() throws -> IAPAuthInfo? { + guard let jsonString = try keychain.get(iapAuthInfoKey), + let jsonData = jsonString.data(using: .utf8) else { + return nil + } + return try JSONDecoder().decode(IAPAuthInfo.self, from: jsonData) + } + + static func setIAPAuthInfo(_ info: IAPAuthInfo) throws { + let jsonData = try JSONEncoder().encode(info) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw NSError(domain: "KeychainManager", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to encode IAP auth info"]) + } + try keychain.set(jsonString, key: iapAuthInfoKey) + } + + static func removeIAPAuthInfo() throws { + try keychain.remove(iapAuthInfoKey) + } + + static func getAuthMode() throws -> AuthMode? { + guard let rawValue = try keychain.get(authModeKey) else { + return nil + } + return AuthMode(rawValue: rawValue) + } + + static func setAuthMode(_ mode: AuthMode) throws { + try keychain.set(mode.rawValue, key: authModeKey) + } + + static func removeAuthMode() throws { + try keychain.remove(authModeKey) + } } diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index 48a1a81..93f9343 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -12,6 +12,7 @@ struct API { struct NDEndpoint { static let login = "/auth/login" + static let loginIAP: String? = "/auth/iap" static let getAlbum = "/api/album" static let getArtists = "/api/artist" static let getPlaylists = "/api/playlist" diff --git a/flo/Shared/Utils/IAPLoginView.swift b/flo/Shared/Utils/IAPLoginView.swift new file mode 100644 index 0000000..c93aafa --- /dev/null +++ b/flo/Shared/Utils/IAPLoginView.swift @@ -0,0 +1,250 @@ +// +// IAPLoginView.swift +// flo +// +// Created by piekay on 08/03/26. +// + +import SwiftUI + +struct IAPLoginView: View { + @ObservedObject var authViewModel: AuthViewModel + @Environment(\.dismiss) private var dismiss + + @State private var serverUrl: String = "" + @State private var showWebAuth = false + @State private var isLoading = false + @State private var errorMessage: String? + @State private var showAdvancedSettings = false + @State private var customHeaderName: String = "" + @State private var customCookieName: String = "" + @State private var customUsernameCookie: String = "" + + var isSubmitButtonDisabled: Bool { + serverUrl.isEmpty || isLoading + } + + init(authViewModel: AuthViewModel) { + self.authViewModel = authViewModel + _serverUrl = State(initialValue: authViewModel.serverUrl) + } + + var body: some View { + ScrollView { + headerSection + formSection + } + .background(Color(.systemBackground)) + .foregroundColor(.accent) + .alert(isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Alert( + title: Text("Authentication Failed"), + message: Text(errorMessage ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) + } + .sheet(isPresented: $showWebAuth) { + IAPWebAuthView( + serverURL: serverUrl, + authViewModel: authViewModel, + customHeaderName: customHeaderName.isEmpty ? nil : customHeaderName, + customCookieName: customCookieName.isEmpty ? nil : customCookieName, + customUsernameCookie: customUsernameCookie.isEmpty ? nil : customUsernameCookie, + onSuccess: { + dismiss() + }, + onError: { error in + errorMessage = error + isLoading = false + } + ) + } + } + + private var headerSection: some View { + VStack { + Image("logo_alt") + .resizable() + .scaledToFit() + .frame(width: 100) + .padding(.vertical, 20) + + Text("Login with IAP") + .customFont(.title1) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.bottom, 10) + + Text("Authenticate using OAuth2-Proxy or Identity-Aware Proxy") + .customFont(.body) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + .padding(.horizontal, 20) + .padding(.vertical, 30) + } + + private var formSection: some View { + VStack { + formField( + title: "Server URL", + text: $serverUrl, + placeholder: "https://your-iap-server.com", + keyboardType: .URL + ) + + advancedSettingsSection + + submitButton + + cancelButton + } + .padding(.bottom, 30) + .padding(.horizontal, 10) + } + + private func formField( + title: String, + text: Binding, + placeholder: String, + keyboardType: UIKeyboardType = .default + ) -> some View { + VStack(alignment: .leading) { + Text(title) + .font(.headline) + TextField(placeholder, text: text) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.accent, lineWidth: 1) + ) + .keyboardType(keyboardType) + .autocapitalization(.none) + .disableAutocorrection(true) + .textContentType(.none) + .disabled(isLoading) + } + .padding(.horizontal, 15) + .padding(.bottom, 10) + } + + private var advancedSettingsSection: some View { + VStack(alignment: .leading, spacing: 12) { + DisclosureGroup("Advanced Settings", isExpanded: $showAdvancedSettings) { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Authentication Token Header Name") + .font(.subheadline) + .fontWeight(.medium) + Text("The HTTP header containing your JWT token (leave empty for auto-detection)") + .font(.caption) + .foregroundStyle(.secondary) + TextField("e.g., x-auth-request-access-token", text: $customHeaderName) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.accent, lineWidth: 1) + ) + .autocapitalization(.none) + .disableAutocorrection(true) + .disabled(isLoading) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Authentication Token Cookie Name") + .font(.subheadline) + .fontWeight(.medium) + Text("The cookie containing your session token (leave empty for auto-detection)") + .font(.caption) + .foregroundStyle(.secondary) + TextField("e.g., _oauth2_proxy, KEYCLOAK_IDENTITY", text: $customCookieName) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.accent, lineWidth: 1) + ) + .autocapitalization(.none) + .disableAutocorrection(true) + .disabled(isLoading) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Username Cookie Name") + .font(.subheadline) + .fontWeight(.medium) + Text("The cookie containing your username (defaults to 'username')") + .font(.caption) + .foregroundStyle(.secondary) + TextField("e.g., username, user, preferred_username", text: $customUsernameCookie) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.accent, lineWidth: 1) + ) + .autocapitalization(.none) + .disableAutocorrection(true) + .disabled(isLoading) + } + } + .padding(.top, 8) + } + } + .padding(.horizontal, 15) + .padding(.bottom, 10) + } + + private var submitButton: some View { + VStack(alignment: .leading) { + Button(action: authenticateWithIAP) { + HStack { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } + + Text(isLoading ? "Authenticating..." : "Authenticate") + .foregroundColor(.white) + .fontWeight(.bold) + .customFont(.headline) + .textCase(.uppercase) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color("PlayerColor")) + .cornerRadius(5) + .opacity(isSubmitButtonDisabled ? 0.5 : 1) + .shadow(radius: isSubmitButtonDisabled ? 0 : 10) + } + .padding(.top, 10) + .padding() + .disabled(isSubmitButtonDisabled) + } + } + + private var cancelButton: some View { + Button(action: { dismiss() }) { + Text("Cancel") + .foregroundColor(Color("PlayerColor")) + .fontWeight(.semibold) + .customFont(.headline) + .padding() + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 15) + } + + private func authenticateWithIAP() { + errorMessage = nil + isLoading = true + authViewModel.serverUrl = serverUrl + showWebAuth = true + } +} + +#Preview { + IAPLoginView(authViewModel: AuthViewModel()) +} diff --git a/flo/Shared/Utils/IAPWebAuthView.swift b/flo/Shared/Utils/IAPWebAuthView.swift new file mode 100644 index 0000000..09957d8 --- /dev/null +++ b/flo/Shared/Utils/IAPWebAuthView.swift @@ -0,0 +1,150 @@ +// +// IAPWebAuthView.swift +// flo +// +// Created by piekay on 13/03/26. +// + +import SwiftUI +import WebKit + +struct IAPWebAuthView: View { + let serverURL: String + @ObservedObject var authViewModel: AuthViewModel + let customHeaderName: String? + let customCookieName: String? + let customUsernameCookie: String? + let onSuccess: () -> Void + let onError: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var isLoading = false + @State private var localError: String? + + var body: some View { + NavigationView { + ZStack { + IAPWebView( + url: serverURL, + customHeaderName: customHeaderName, + customCookieName: customCookieName, + customUsernameCookie: customUsernameCookie, + onDataExtracted: { jwt, username, webView in + handleAuthentication(jwt: jwt, username: username, webView: webView) + }, + onError: { error in + handleError(error) + } + ) + + if let error = localError { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 50)) + .foregroundStyle(.orange) + + Text("Authentication Failed") + .font(.headline) + + Text(error) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Try Again") { + localError = nil + } + .buttonStyle(.borderedProminent) + + Button("Cancel") { + dismiss() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + } + } + .navigationTitle("Sign In") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } + + private func handleAuthentication(jwt: String, username: String, webView: WKWebView) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + for cookie in cookies { + HTTPCookieStorage.shared.setCookie(cookie) + } + + self.completeOAuthLogin(jwt: jwt, username: username) + } + } + + private func completeOAuthLogin(jwt: String, username: String) { + let iapInfo = IAPAuthInfo(jwtAssertion: jwt, userEmail: username, userId: nil) + AuthService.shared.setIAPAuthInfo(iapInfo) + AuthService.shared.setAuthMode(AuthMode.iap) + + let userAuth = UserAuth( + id: username, + username: username, + name: username, + isAdmin: false, + lastFMApiKey: "", + subsonicSalt: "", + subsonicToken: "", + token: jwt + ) + + let testURL = "\(serverURL)/api/ping" + + var request = URLRequest(url: URL(string: testURL)!) + request.httpMethod = "GET" + + URLSession.shared.dataTask(with: request) { data, response, error in + if let httpResponse = response as? HTTPURLResponse { + if httpResponse.statusCode == 200 { + DispatchQueue.main.async { + self.authViewModel.persistAuthData(userAuth) + self.authViewModel.authMode = .iap + self.authViewModel.isLoggedIn = true + self.authViewModel.user = userAuth + + self.dismiss() + self.onSuccess() + } + } else if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + DispatchQueue.main.async { + self.handleError("Something went wrong with IAP Authentication.") + } + } else { + DispatchQueue.main.async { + self.authViewModel.persistAuthData(userAuth) + self.authViewModel.authMode = .iap + self.authViewModel.isLoggedIn = true + self.authViewModel.user = userAuth + + self.dismiss() + self.onSuccess() + } + } + } else { + DispatchQueue.main.async { + self.handleError("Could not verify authentication. Please check your network connection.") + } + } + }.resume() + } + + private func handleError(_ error: String) { + localError = error + onError(error) + } +} diff --git a/flo/Shared/Utils/IAPWebView.swift b/flo/Shared/Utils/IAPWebView.swift new file mode 100644 index 0000000..7a0f759 --- /dev/null +++ b/flo/Shared/Utils/IAPWebView.swift @@ -0,0 +1,250 @@ +// +// IAPWebView.swift +// flo +// +// Created by piekay on 08/03/26. +// + +import SwiftUI +import WebKit + +struct IAPWebView: UIViewRepresentable { + let url: String + let customHeaderName: String? + let customCookieName: String? + let customUsernameCookie: String? + let onDataExtracted: (String, String, WKWebView) -> Void + let onError: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + customHeaderName: customHeaderName, + customCookieName: customCookieName, + customUsernameCookie: customUsernameCookie, + onDataExtracted: onDataExtracted, + onError: onError + ) + } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + context.coordinator.webView = webView + context.coordinator.originalServerURL = url + + if let url = URL(string: url) { + let request = URLRequest(url: url) + webView.load(request) + } else { + onError("Invalid server URL") + } + + return webView + } + + func updateUIView(_ uiView: WKWebView, context: Context) {} + + class Coordinator: NSObject, WKNavigationDelegate { + let customHeaderName: String? + let customCookieName: String? + let customUsernameCookie: String? + let onDataExtracted: (String, String, WKWebView) -> Void + let onError: (String) -> Void + private var hasExtractedData = false + private var requestCount = 0 + private let maxRequests = 10 + weak var webView: WKWebView? + var originalServerURL: String = "" + + init( + customHeaderName: String?, + customCookieName: String?, + customUsernameCookie: String?, + onDataExtracted: @escaping (String, String, WKWebView) -> Void, + onError: @escaping (String) -> Void + ) { + self.customHeaderName = customHeaderName + self.customCookieName = customCookieName + self.customUsernameCookie = customUsernameCookie + self.onDataExtracted = onDataExtracted + self.onError = onError + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + requestCount += 1 + + if !hasExtractedData, + let httpResponse = navigationResponse.response as? HTTPURLResponse { + + if httpResponse.statusCode >= 400 { + decisionHandler(.allow) + return + } + + let headers = httpResponse.allHeaderFields + + var possibleTokenHeaders = [ + "x-auth-request-access-token", + "x-auth-token", + "x-forwarded-access-token", + "authorization" + ] + + if let customHeader = customHeaderName, !customHeader.isEmpty { + possibleTokenHeaders.insert(customHeader.lowercased(), at: 0) + } + + var extractedToken: String? + + for (key, value) in headers { + if let headerName = key as? String { + let normalizedHeader = headerName.lowercased() + + if possibleTokenHeaders.contains(normalizedHeader), let token = value as? String { + extractedToken = token.replacingOccurrences(of: "Bearer ", with: "") + break + } + } + } + + if let token = extractedToken { + if let responseURL = httpResponse.url?.absoluteString { + let normalizedResponse = self.normalizeURL(responseURL) + let normalizedOriginal = self.normalizeURL(self.originalServerURL) + + if normalizedResponse.hasPrefix(normalizedOriginal) { + hasExtractedData = true + DispatchQueue.main.async { + if let webView = self.webView { + self.extractUsernameFromCookies(token: token, webView: webView) + } + } + decisionHandler(.cancel) + return + } + } + } + } + + if requestCount > maxRequests && !hasExtractedData { + DispatchQueue.main.async { + self.onError("Could not find authentication token after multiple redirects. Make sure your server uses OAuth2-Proxy or IAP.") + } + decisionHandler(.cancel) + return + } + + decisionHandler(.allow) + } + + private func extractUsernameFromCookies(token: String, webView: WKWebView) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in + guard let self = self else { return } + + var username = "OAuth User" + + let usernameCookieName = self.customUsernameCookie ?? "username" + + for cookie in cookies where cookie.name == usernameCookieName { + username = cookie.value + break + } + + DispatchQueue.main.async { + self.onDataExtracted(token, username, webView) + } + } + } + + private func normalizeURL(_ urlString: String) -> String { + guard let url = URL(string: urlString) else { return urlString } + + var components = URLComponents() + components.scheme = url.scheme + components.host = url.host + components.port = url.port + components.path = url.path + + var normalized = components.string ?? urlString + if normalized.hasSuffix("/") { + normalized = String(normalized.dropLast()) + } + + return normalized.lowercased() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard let currentURL = webView.url?.absoluteString else { return } + let normalizedCurrent = normalizeURL(currentURL) + let normalizedOriginal = normalizeURL(originalServerURL) + + if !hasExtractedData && normalizedCurrent.hasPrefix(normalizedOriginal) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + var extractedToken: String? + + if let customCookie = self.customCookieName, !customCookie.isEmpty { + for cookie in cookies where cookie.name == customCookie { + extractedToken = cookie.value + break + } + } + + if extractedToken == nil { + for cookie in cookies where cookie.name == "KEYCLOAK_IDENTITY" { + extractedToken = cookie.value + break + } + } + + if extractedToken == nil { + for cookie in cookies where cookie.name.hasPrefix("_oauth2_proxy") { + extractedToken = cookie.value + break + } + } + + if let token = extractedToken, !token.isEmpty, let webView = self.webView { + var username = "OAuth User" + + let usernameCookieName = self.customUsernameCookie ?? "username" + + for cookie in cookies where cookie.name == usernameCookieName { + username = cookie.value + break + } + + self.hasExtractedData = true + DispatchQueue.main.async { + self.onDataExtracted(token, username, webView) + } + } + } + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if !hasExtractedData { + DispatchQueue.main.async { + self.onError("Failed to load server: \(error.localizedDescription)") + } + } + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + if !hasExtractedData { + DispatchQueue.main.async { + self.onError("Failed to connect: \(error.localizedDescription)") + } + } + } + } +}