From 185d59b0887aaa1932a2f3436f429ec9f0160dc7 Mon Sep 17 00:00:00 2001 From: Piekay Date: Sun, 8 Mar 2026 18:13:23 +0100 Subject: [PATCH 1/3] added iap support --- flo.xcodeproj/project.pbxproj | 8 + flo/AuthMode.swift | 25 ++ flo/AuthViewModel.swift | 74 +++- flo/Info.plist | 2 - flo/LoginView.swift | 51 +++ flo/Resources/Localizable.xcstrings | 54 ++- flo/Shared/Services/APIManager.swift | 24 ++ flo/Shared/Services/AuthService.swift | 121 ++++++ flo/Shared/Services/KeychainManager.swift | 49 +++ flo/Shared/Utils/Constants.swift | 1 + flo/Shared/Utils/IAPLoginView.swift | 496 ++++++++++++++++++++++ 11 files changed, 901 insertions(+), 4 deletions(-) create mode 100644 flo/AuthMode.swift create mode 100644 flo/Shared/Utils/IAPLoginView.swift diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index 1ae920a..b6c27a9 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ 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 */; }; 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 +149,8 @@ /* 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 = ""; }; 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 = ""; }; @@ -327,6 +331,7 @@ C415F5632C11AA8700E3E1D2 /* Fonts.swift */, C49134522C15BE0C00CCF2EB /* Strings.swift */, C4120FD82C15D58E00E712BE /* Errors.swift */, + 50C9128A2F5DD9990087EE61 /* IAPLoginView.swift */, ); path = Utils; sourceTree = ""; @@ -457,6 +462,7 @@ C4DFFA202D32E769003B9C4E /* DownloadViewModel.swift */, C456D8FD2F300D37002AAB8B /* LyricsView.swift */, C42B25662F44533D00E62008 /* Watch */, + 50C912872F5DD9280087EE61 /* AuthMode.swift */, ); path = flo; sourceTree = ""; @@ -650,6 +656,7 @@ C47876022C2BF15900184A33 /* AlbumsView.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 +684,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..a761df5 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,59 @@ 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 + + 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..18d3e15 100644 --- a/flo/Info.plist +++ b/flo/Info.plist @@ -2,8 +2,6 @@ - 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/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 66944af..0ee5067 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -299,6 +299,22 @@ } } }, + "Authenticate using Identity-Aware Proxy" : { + "comment" : "A label describing the action of authenticating using Identity-Aware Proxy.", + "isCommentAutoGenerated" : true + }, + "Authenticate with IAP" : { + "comment" : "A button label that initiates the IAP authentication flow.", + "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 + }, "Bring your music anywhere, even when you're offline. Your downloaded music will be here." : { "localizations" : { "en" : { @@ -714,6 +730,18 @@ } } }, + "https://your-iap-server.com" : { + "comment" : "A placeholder text for the user to input their IAP server URL.", + "isCommentAutoGenerated" : true + }, + "IAP Authentication" : { + "comment" : "The title of the IAP Authentication screen.", + "isCommentAutoGenerated" : true + }, + "IAP Login" : { + "comment" : "The title of the view.", + "isCommentAutoGenerated" : true + }, "Keychain.%@" : { "localizations" : { "id" : { @@ -872,6 +900,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 +1060,10 @@ } } }, + "OR" : { + "comment" : "A label used to separate different options in a list.", + "isCommentAutoGenerated" : true + }, "Play" : { "localizations" : { "en" : { @@ -1413,6 +1449,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" : { @@ -1634,6 +1674,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 +1698,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" : { @@ -1679,7 +1727,11 @@ } } } + }, + "Your server URL will be opened to authenticate. The IAP token will be extracted automatically." : { + "comment" : "A description of the process of using Identity-Aware Proxy (IAP) for authentication.", + "isCommentAutoGenerated" : true } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file 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..b73882f --- /dev/null +++ b/flo/Shared/Utils/IAPLoginView.swift @@ -0,0 +1,496 @@ +// +// IAPLoginView.swift +// flo +// +// Created by piekay on 08/03/26. +// + +import SwiftUI +import WebKit + +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? + + var body: some View { + NavigationView { + oauthLoginView + } + } + + private var oauthLoginView: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 60)) + .foregroundStyle(.blue) + + Text("IAP Authentication") + .font(.title) + .fontWeight(.bold) + + Text("Authenticate using Identity-Aware Proxy") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.top, 40) + + HStack(spacing: 12) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue) + + Text("Your server URL will be opened to authenticate. The IAP token will be extracted automatically.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(Color.blue.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 8) { + Text("Server URL") + .font(.headline) + + TextField("https://your-iap-server.com", text: $serverUrl) + .textFieldStyle(.roundedBorder) + .autocapitalization(.none) + .keyboardType(.URL) + .autocorrectionDisabled() + .disabled(isLoading) + } + .padding(.horizontal) + + Button(action: { + authenticateWithIAP() + }) { + HStack { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } + + Text(isLoading ? "Authenticating..." : "Authenticate with IAP") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(canSubmit ? Color.blue : Color.gray) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(!canSubmit || isLoading) + .padding(.horizontal) + + if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + .navigationTitle("IAP Login") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .sheet(isPresented: $showWebAuth) { + IAPWebAuthView( + serverURL: serverUrl, + authViewModel: authViewModel, + onSuccess: { + dismiss() + }, + onError: { error in + errorMessage = error + isLoading = false + } + ) + } + } + + private var canSubmit: Bool { + !serverUrl.isEmpty && !isLoading + } + + private func authenticateWithIAP() { + errorMessage = nil + isLoading = true + authViewModel.serverUrl = serverUrl + showWebAuth = true + } +} + +private struct IAPWebAuthView: View { + let serverURL: String + @ObservedObject var authViewModel: AuthViewModel + 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, + onJWTExtracted: { jwt, webView in + handleJWT(jwt, 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 handleJWT(_ jwt: String, webView: WKWebView) { + let username = extractUsernameFromJWT(jwt) ?? "OAuth User" + + 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.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.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 extractUsernameFromJWT(_ jwt: String) -> String? { + let segments = jwt.components(separatedBy: ".") + guard segments.count > 1 else { return nil } + + let payloadSegment = segments[1] + + // Add padding if needed for base64 decoding + 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 + } + + print("📋 JWT Payload: \(json)") + + // Try different possible username fields + if let username = json["preferred_username"] as? String { + return username + } else if let username = json["email"] as? String { + return username + } else if let username = json["sub"] as? String { + return username + } + + return nil + } + + private func handleError(_ error: String) { + localError = error + onError(error) + } +} + +private struct IAPWebView: UIViewRepresentable { + let url: String + let onJWTExtracted: (String, WKWebView) -> Void + let onError: (String) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onJWTExtracted: onJWTExtracted, 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 onJWTExtracted: (String, WKWebView) -> Void + let onError: (String) -> Void + private var hasExtractedJWT = false + private var requestCount = 0 + private let maxRequests = 10 + weak var webView: WKWebView? + var originalServerURL: String = "" + + init(onJWTExtracted: @escaping (String, WKWebView) -> Void, onError: @escaping (String) -> Void) { + self.onJWTExtracted = onJWTExtracted + self.onError = onError + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + requestCount += 1 + + if !hasExtractedJWT, + let httpResponse = navigationResponse.response as? HTTPURLResponse { + + if httpResponse.statusCode >= 400 { + decisionHandler(.allow) + return + } + + let headers = httpResponse.allHeaderFields + + let possibleTokenHeaders = [ + "x-goog-iap-jwt-assertion", + "x-auth-request-access-token", + "x-auth-token", + "x-forwarded-access-token", + "authorization" + ] + + for (key, value) in headers { + if let headerName = key as? String { + let normalizedHeader = headerName.lowercased() + + if possibleTokenHeaders.contains(normalizedHeader) { + if let token = value as? String { + let cleanToken = token.replacingOccurrences(of: "Bearer ", with: "") + + if let responseURL = httpResponse.url?.absoluteString { + + let normalizedResponse = self.normalizeURL(responseURL) + let normalizedOriginal = self.normalizeURL(self.originalServerURL) + + if normalizedResponse.hasPrefix(normalizedOriginal) { + hasExtractedJWT = true + DispatchQueue.main.async { + if let webView = self.webView { + self.onJWTExtracted(cleanToken, webView) + } + } + decisionHandler(.cancel) + return + } else {} + } + } + } + } + } + } + + if requestCount > maxRequests && !hasExtractedJWT { + 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 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 !hasExtractedJWT && normalizedCurrent.hasPrefix(normalizedOriginal) { + + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + + for cookie in cookies { + if cookie.name == "KEYCLOAK_IDENTITY" { + let jwt = cookie.value + self.hasExtractedJWT = true + DispatchQueue.main.async { + if let webView = self.webView { + self.onJWTExtracted(jwt, webView) + } + } + return + } + } + + // Fallback: look for OAuth2-Proxy session cookie + for cookie in cookies { + if cookie.name.hasPrefix("_oauth2_proxy") { + self.hasExtractedJWT = true + DispatchQueue.main.async { + if let webView = self.webView { + self.onJWTExtracted(cookie.value, webView) + } + } + return + } + } + + if !self.hasExtractedJWT {} + } + } else if !normalizedCurrent.hasPrefix(normalizedOriginal) {} + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + if !hasExtractedJWT { + DispatchQueue.main.async { + self.onError("Failed to load server: \(error.localizedDescription)") + } + } + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + if !hasExtractedJWT { + DispatchQueue.main.async { + self.onError("Failed to connect: \(error.localizedDescription)") + } + } + } + } +} + +#Preview { + IAPLoginView(authViewModel: AuthViewModel()) +} From 75ed9903b4ff131d4c72f6dd0fc4fb4a3c15515c Mon Sep 17 00:00:00 2001 From: Piekay Date: Fri, 13 Mar 2026 19:18:39 +0100 Subject: [PATCH 2/3] added configurable headers and cookies, ui cleanup and code cleanup --- flo.xcodeproj/project.pbxproj | 8 + flo/Info.plist | 2 + flo/Resources/Localizable.xcstrings | 64 ++- flo/Shared/Utils/IAPLoginView.swift | 614 ++++++++------------------ flo/Shared/Utils/IAPWebAuthView.swift | 148 +++++++ flo/Shared/Utils/IAPWebView.swift | 250 +++++++++++ 6 files changed, 636 insertions(+), 450 deletions(-) create mode 100644 flo/Shared/Utils/IAPWebAuthView.swift create mode 100644 flo/Shared/Utils/IAPWebView.swift diff --git a/flo.xcodeproj/project.pbxproj b/flo.xcodeproj/project.pbxproj index b6c27a9..4ff50b0 100644 --- a/flo.xcodeproj/project.pbxproj +++ b/flo.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* 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 */; }; @@ -151,6 +153,8 @@ /* 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 = ""; }; @@ -324,6 +328,8 @@ C4289F4D2C1253EB00C3A4FD /* Utils */ = { isa = PBXGroup; children = ( + 50C912A52F648A490087EE61 /* IAPWebView.swift */, + 50C912A32F648A440087EE61 /* IAPWebAuthView.swift */, B02A003E2F3666240024E8EC /* UIScreen+.swift */, C456D8F92F2FF33B002AAB8B /* LRCParser.swift */, C4F0B0A12F3A111100ABC002 /* AirPlayRoutePicker.swift */, @@ -639,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 */, @@ -654,6 +661,7 @@ 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 */, diff --git a/flo/Info.plist b/flo/Info.plist index 18d3e15..0e74e0a 100644 --- a/flo/Info.plist +++ b/flo/Info.plist @@ -2,6 +2,8 @@ + ITSAppUsesNonExemptEncryption + UIAppFonts PlusJakartaSans-VariableFont_wght.ttf diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 0ee5067..8a49285 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,12 +303,12 @@ } } }, - "Authenticate using Identity-Aware Proxy" : { - "comment" : "A label describing the action of authenticating using Identity-Aware Proxy.", + "Authenticate" : { + "comment" : "The text of the button that allows a user to authenticate with their in-app purchases.", "isCommentAutoGenerated" : true }, - "Authenticate with IAP" : { - "comment" : "A button label that initiates the IAP authentication flow.", + "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..." : { @@ -315,6 +319,14 @@ "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" : { @@ -553,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" : { @@ -730,18 +754,6 @@ } } }, - "https://your-iap-server.com" : { - "comment" : "A placeholder text for the user to input their IAP server URL.", - "isCommentAutoGenerated" : true - }, - "IAP Authentication" : { - "comment" : "The title of the IAP Authentication screen.", - "isCommentAutoGenerated" : true - }, - "IAP Login" : { - "comment" : "The title of the view.", - "isCommentAutoGenerated" : true - }, "Keychain.%@" : { "localizations" : { "id" : { @@ -1538,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" : { @@ -1548,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" : { @@ -1712,6 +1736,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" : { @@ -1727,10 +1755,6 @@ } } } - }, - "Your server URL will be opened to authenticate. The IAP token will be extracted automatically." : { - "comment" : "A description of the process of using Identity-Aware Proxy (IAP) for authentication.", - "isCommentAutoGenerated" : true } }, "version" : "1.1" diff --git a/flo/Shared/Utils/IAPLoginView.swift b/flo/Shared/Utils/IAPLoginView.swift index b73882f..c93aafa 100644 --- a/flo/Shared/Utils/IAPLoginView.swift +++ b/flo/Shared/Utils/IAPLoginView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import WebKit struct IAPLoginView: View { @ObservedObject var authViewModel: AuthViewModel @@ -16,100 +15,44 @@ struct IAPLoginView: View { @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 body: some View { - NavigationView { - oauthLoginView - } + var isSubmitButtonDisabled: Bool { + serverUrl.isEmpty || isLoading } - - private var oauthLoginView: some View { - VStack(spacing: 24) { - VStack(spacing: 8) { - Image(systemName: "lock.shield.fill") - .font(.system(size: 60)) - .foregroundStyle(.blue) - - Text("IAP Authentication") - .font(.title) - .fontWeight(.bold) - - Text("Authenticate using Identity-Aware Proxy") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding(.top, 40) - - HStack(spacing: 12) { - Image(systemName: "info.circle.fill") - .foregroundStyle(.blue) - - Text("Your server URL will be opened to authenticate. The IAP token will be extracted automatically.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding() - .background(Color.blue.opacity(0.1)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.horizontal) - - VStack(alignment: .leading, spacing: 8) { - Text("Server URL") - .font(.headline) - - TextField("https://your-iap-server.com", text: $serverUrl) - .textFieldStyle(.roundedBorder) - .autocapitalization(.none) - .keyboardType(.URL) - .autocorrectionDisabled() - .disabled(isLoading) - } - .padding(.horizontal) - - Button(action: { - authenticateWithIAP() - }) { - HStack { - if isLoading { - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } - - Text(isLoading ? "Authenticating..." : "Authenticate with IAP") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(canSubmit ? Color.blue : Color.gray) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - .disabled(!canSubmit || isLoading) - .padding(.horizontal) - - if let error = errorMessage { - Text(error) - .font(.caption) - .foregroundStyle(.red) - .multilineTextAlignment(.center) - .padding(.horizontal) - } + + init(authViewModel: AuthViewModel) { + self.authViewModel = authViewModel + _serverUrl = State(initialValue: authViewModel.serverUrl) + } + + var body: some View { + ScrollView { + headerSection + formSection } - .navigationTitle("IAP Login") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } + .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() }, @@ -120,374 +63,185 @@ struct IAPLoginView: View { ) } } - - private var canSubmit: Bool { - !serverUrl.isEmpty && !isLoading + + 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 func authenticateWithIAP() { - errorMessage = nil - isLoading = true - authViewModel.serverUrl = serverUrl - showWebAuth = true + 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 struct IAPWebAuthView: View { - let serverURL: String - @ObservedObject var authViewModel: AuthViewModel - let onSuccess: () -> Void - let onError: (String) -> Void - @Environment(\.dismiss) private var dismiss - @State private var isLoading = false - @State private var localError: String? + 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) + } - var body: some View { - NavigationView { - ZStack { - IAPWebView( - url: serverURL, - onJWTExtracted: { jwt, webView in - handleJWT(jwt, webView: webView) - }, - onError: { error in - handleError(error) + 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) } - ) - - 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) + + 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) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Button("Try Again") { - localError = nil - } - .buttonStyle(.borderedProminent) - - Button("Cancel") { - dismiss() - } + TextField("e.g., _oauth2_proxy, KEYCLOAK_IDENTITY", text: $customCookieName) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(.accent, lineWidth: 1) + ) + .autocapitalization(.none) + .disableAutocorrection(true) + .disabled(isLoading) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemBackground)) - } - } - .navigationTitle("Sign In") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() + + 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 func handleJWT(_ jwt: String, webView: WKWebView) { - let username = extractUsernameFromJWT(jwt) ?? "OAuth User" - - 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.isLoggedIn = true - self.authViewModel.user = userAuth - - self.dismiss() - self.onSuccess() + private var submitButton: some View { + VStack(alignment: .leading) { + Button(action: authenticateWithIAP) { + HStack { + if isLoading { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) } - } 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.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.") + 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) } - }.resume() - } - - private func extractUsernameFromJWT(_ jwt: String) -> String? { - let segments = jwt.components(separatedBy: ".") - guard segments.count > 1 else { return nil } - - let payloadSegment = segments[1] - - // Add padding if needed for base64 decoding - 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 - } - - print("📋 JWT Payload: \(json)") - - // Try different possible username fields - if let username = json["preferred_username"] as? String { - return username - } else if let username = json["email"] as? String { - return username - } else if let username = json["sub"] as? String { - return username + .padding(.top, 10) + .padding() + .disabled(isSubmitButtonDisabled) } - - return nil - } - - private func handleError(_ error: String) { - localError = error - onError(error) } -} - -private struct IAPWebView: UIViewRepresentable { - let url: String - let onJWTExtracted: (String, WKWebView) -> Void - let onError: (String) -> Void - func makeCoordinator() -> Coordinator { - Coordinator(onJWTExtracted: onJWTExtracted, 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") + private var cancelButton: some View { + Button(action: { dismiss() }) { + Text("Cancel") + .foregroundColor(Color("PlayerColor")) + .fontWeight(.semibold) + .customFont(.headline) + .padding() + .frame(maxWidth: .infinity) } - - return webView + .padding(.horizontal, 15) } - func updateUIView(_ uiView: WKWebView, context: Context) {} - - class Coordinator: NSObject, WKNavigationDelegate { - let onJWTExtracted: (String, WKWebView) -> Void - let onError: (String) -> Void - private var hasExtractedJWT = false - private var requestCount = 0 - private let maxRequests = 10 - weak var webView: WKWebView? - var originalServerURL: String = "" - - init(onJWTExtracted: @escaping (String, WKWebView) -> Void, onError: @escaping (String) -> Void) { - self.onJWTExtracted = onJWTExtracted - self.onError = onError - } - - func webView( - _ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void - ) { - requestCount += 1 - - if !hasExtractedJWT, - let httpResponse = navigationResponse.response as? HTTPURLResponse { - - if httpResponse.statusCode >= 400 { - decisionHandler(.allow) - return - } - - let headers = httpResponse.allHeaderFields - - let possibleTokenHeaders = [ - "x-goog-iap-jwt-assertion", - "x-auth-request-access-token", - "x-auth-token", - "x-forwarded-access-token", - "authorization" - ] - - for (key, value) in headers { - if let headerName = key as? String { - let normalizedHeader = headerName.lowercased() - - if possibleTokenHeaders.contains(normalizedHeader) { - if let token = value as? String { - let cleanToken = token.replacingOccurrences(of: "Bearer ", with: "") - - if let responseURL = httpResponse.url?.absoluteString { - - let normalizedResponse = self.normalizeURL(responseURL) - let normalizedOriginal = self.normalizeURL(self.originalServerURL) - - if normalizedResponse.hasPrefix(normalizedOriginal) { - hasExtractedJWT = true - DispatchQueue.main.async { - if let webView = self.webView { - self.onJWTExtracted(cleanToken, webView) - } - } - decisionHandler(.cancel) - return - } else {} - } - } - } - } - } - } - - if requestCount > maxRequests && !hasExtractedJWT { - 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 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 !hasExtractedJWT && normalizedCurrent.hasPrefix(normalizedOriginal) { - - webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in - - for cookie in cookies { - if cookie.name == "KEYCLOAK_IDENTITY" { - let jwt = cookie.value - self.hasExtractedJWT = true - DispatchQueue.main.async { - if let webView = self.webView { - self.onJWTExtracted(jwt, webView) - } - } - return - } - } - - // Fallback: look for OAuth2-Proxy session cookie - for cookie in cookies { - if cookie.name.hasPrefix("_oauth2_proxy") { - self.hasExtractedJWT = true - DispatchQueue.main.async { - if let webView = self.webView { - self.onJWTExtracted(cookie.value, webView) - } - } - return - } - } - - if !self.hasExtractedJWT {} - } - } else if !normalizedCurrent.hasPrefix(normalizedOriginal) {} - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - if !hasExtractedJWT { - DispatchQueue.main.async { - self.onError("Failed to load server: \(error.localizedDescription)") - } - } - } - - func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: Error - ) { - if !hasExtractedJWT { - DispatchQueue.main.async { - self.onError("Failed to connect: \(error.localizedDescription)") - } - } - } + private func authenticateWithIAP() { + errorMessage = nil + isLoading = true + authViewModel.serverUrl = serverUrl + showWebAuth = true } } diff --git a/flo/Shared/Utils/IAPWebAuthView.swift b/flo/Shared/Utils/IAPWebAuthView.swift new file mode 100644 index 0000000..e1a3ef1 --- /dev/null +++ b/flo/Shared/Utils/IAPWebAuthView.swift @@ -0,0 +1,148 @@ +// +// 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.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.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)") + } + } + } + } +} From e71973aac5bd92ef264cbf3514bc27c70f8bbe68 Mon Sep 17 00:00:00 2001 From: Piekay Date: Sat, 14 Mar 2026 21:51:29 +0100 Subject: [PATCH 3/3] disabled "save login info" for IAP users --- flo/AuthViewModel.swift | 4 ++++ flo/Navigation/PreferencesView.swift | 5 ++++- flo/Resources/Localizable.xcstrings | 7 +++++-- flo/Shared/Utils/IAPWebAuthView.swift | 2 ++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/flo/AuthViewModel.swift b/flo/AuthViewModel.swift index a761df5..048c548 100644 --- a/flo/AuthViewModel.swift +++ b/flo/AuthViewModel.swift @@ -200,6 +200,10 @@ class AuthViewModel: ObservableObject { self.authMode = .iap + if UserDefaultsManager.saveLoginInfo { + self.destroySavedPassword() + } + DispatchQueue.main.async { self.isSubmitting = false self.isLoggedIn = true 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 8a49285..676056c 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -613,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" : { @@ -1603,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" : { @@ -1758,4 +1761,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/flo/Shared/Utils/IAPWebAuthView.swift b/flo/Shared/Utils/IAPWebAuthView.swift index e1a3ef1..09957d8 100644 --- a/flo/Shared/Utils/IAPWebAuthView.swift +++ b/flo/Shared/Utils/IAPWebAuthView.swift @@ -113,6 +113,7 @@ struct IAPWebAuthView: View { if httpResponse.statusCode == 200 { DispatchQueue.main.async { self.authViewModel.persistAuthData(userAuth) + self.authViewModel.authMode = .iap self.authViewModel.isLoggedIn = true self.authViewModel.user = userAuth @@ -126,6 +127,7 @@ struct IAPWebAuthView: View { } else { DispatchQueue.main.async { self.authViewModel.persistAuthData(userAuth) + self.authViewModel.authMode = .iap self.authViewModel.isLoggedIn = true self.authViewModel.user = userAuth