diff --git a/flo/AuthMode.swift b/flo/AuthMode.swift index c15caad..30ad5a3 100644 --- a/flo/AuthMode.swift +++ b/flo/AuthMode.swift @@ -11,15 +11,3 @@ 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 0916692..d45d26d 100644 --- a/flo/AuthViewModel.swift +++ b/flo/AuthViewModel.swift @@ -29,8 +29,6 @@ class AuthViewModel: ObservableObject { @Published var isLoggedIn: Bool = false @Published var authMode: AuthMode = .standard - @Published var iapJwtAssertion: String = "" - @Published var useIAPAuth: Bool = false static let shared = AuthViewModel() @@ -56,18 +54,20 @@ class AuthViewModel: ObservableObject { authMode = AuthService.shared.getAuthMode() - if UserDefaultsManager.saveLoginInfo { + if authMode == .iap { + user = UserAuth( + id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, + lastFMApiKey: data.lastFMApiKey + ) + isLoggedIn = true + } else if UserDefaultsManager.saveLoginInfo { do { password = try KeychainManager.getAuthPassword() ?? "" } catch { print("Error loading password from Keychain: \(error)") } - if authMode == .iap, let iapInfo = AuthService.shared.getIAPAuthInfo() { - loginWithIAP(jwtAssertion: iapInfo.jwtAssertion) - } else { - login() - } + login() } else { user = UserAuth( id: data.id, username: data.username, name: data.name, isAdmin: data.isAdmin, @@ -135,9 +135,7 @@ class AuthViewModel: ObservableObject { destroySavedPassword() if authMode == .iap { - try? KeychainManager.removeIAPAuthInfo() try? KeychainManager.removeAuthMode() - AuthService.shared.clearIAPAuthInfo() } UserDefaultsManager.removeObject(key: UserDefaultsKeys.serverURL) @@ -184,61 +182,4 @@ class AuthViewModel: ObservableObject { } } - func loginWithIAP(jwtAssertion: String? = nil) { - isSubmitting = true - - let jwt = jwtAssertion ?? 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/PlayerViewModel.swift b/flo/PlayerViewModel.swift index 4b85f37..96c2d78 100644 --- a/flo/PlayerViewModel.swift +++ b/flo/PlayerViewModel.swift @@ -225,7 +225,13 @@ class PlayerViewModel: ObservableObject { self._playFromLocal = audioURL.isFileURL - self.playerItem = AVPlayerItem(url: audioURL) + if !audioURL.isFileURL, AuthService.shared.getAuthMode() == .iap { + let cookies = HTTPCookieStorage.shared.cookies(for: audioURL) ?? [] + let asset = AVURLAsset(url: audioURL, options: [AVURLAssetHTTPCookiesKey: cookies]) + self.playerItem = AVPlayerItem(asset: asset) + } else { + self.playerItem = AVPlayerItem(url: audioURL) + } self.player?.replaceCurrentItem(with: self.playerItem) let duration = CMTime( diff --git a/flo/Resources/Localizable.xcstrings b/flo/Resources/Localizable.xcstrings index 521b84b..0d944e7 100644 --- a/flo/Resources/Localizable.xcstrings +++ b/flo/Resources/Localizable.xcstrings @@ -448,6 +448,7 @@ }, "Advanced Settings" : { "comment" : "A toggle label for advanced settings in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -699,6 +700,7 @@ }, "Authentication Token Cookie Name" : { "comment" : "A description of the authentication token cookie name setting.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -711,6 +713,7 @@ }, "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.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1198,6 +1201,7 @@ }, "e.g., _oauth2_proxy, KEYCLOAK_IDENTITY" : { "comment" : "A placeholder text for the \"Authentication Token Cookie Name\" field in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1210,6 +1214,7 @@ }, "e.g., username, user, preferred_username" : { "comment" : "A placeholder text for the \"Username Cookie Name\" field in the advanced settings of the IAP login view.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1221,6 +1226,7 @@ }, "e.g., x-auth-request-access-token" : { "comment" : "A placeholder text for the authentication token header name field in the advanced settings of the IAP login view.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3308,6 +3314,7 @@ }, "The cookie containing your session token (leave empty for auto-detection)" : { "comment" : "A description of the purpose of the \"Authentication Token Cookie Name\" field.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3320,6 +3327,7 @@ }, "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.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3354,6 +3362,7 @@ }, "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.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -3743,6 +3752,7 @@ }, "Username Cookie Name" : { "comment" : "A label for the username cookie name field in the IAP login view.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { diff --git a/flo/Shared/Models/UserAuth.swift b/flo/Shared/Models/UserAuth.swift index 692a64c..0d22471 100644 --- a/flo/Shared/Models/UserAuth.swift +++ b/flo/Shared/Models/UserAuth.swift @@ -41,6 +41,6 @@ struct UserAuth: Codable { self.lastFMApiKey = try container.decodeIfPresent(String.self, forKey: .lastFMApiKey) ?? "" self.subsonicSalt = try container.decode(String.self, forKey: .subsonicSalt) self.subsonicToken = try container.decode(String.self, forKey: .subsonicToken) - self.token = try container.decode(String.self, forKey: .token) + self.token = try container.decodeIfPresent(String.self, forKey: .token) ?? "" } } diff --git a/flo/Shared/Services/APIManager.swift b/flo/Shared/Services/APIManager.swift index de5d9fe..5ec2f4a 100644 --- a/flo/Shared/Services/APIManager.swift +++ b/flo/Shared/Services/APIManager.swift @@ -172,30 +172,6 @@ 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 3bc21cc..a2b700a 100644 --- a/flo/Shared/Services/AuthService.swift +++ b/flo/Shared/Services/AuthService.swift @@ -15,7 +15,6 @@ 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(), @@ -27,14 +26,10 @@ 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 { @@ -49,23 +44,13 @@ 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 = @@ -79,16 +64,6 @@ class AuthService { 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, @@ -128,84 +103,4 @@ 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 d1c48ac..2a21870 100644 --- a/flo/Shared/Services/KeychainManager.swift +++ b/flo/Shared/Services/KeychainManager.swift @@ -29,7 +29,6 @@ class KeychainManager { .accessibility(.afterFirstUnlockThisDeviceOnly) #endif - private static let iapAuthInfoKey = "iapAuthInfo" private static let authModeKey = "authMode" static func getAuthCredsAndPasswords() -> [String: Any] { @@ -116,45 +115,6 @@ class KeychainManager { #endif } - static func getIAPAuthInfo() throws -> IAPAuthInfo? { - #if targetEnvironment(macCatalyst) - guard let jsonString = try store.get(iapAuthInfoKey), - let jsonData = jsonString.data(using: .utf8) - else { - return nil - } - #else - guard let jsonString = try keychain.get(iapAuthInfoKey), - let jsonData = jsonString.data(using: .utf8) - else { - return nil - } - #endif - 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"]) - } - #if targetEnvironment(macCatalyst) - try store.set(jsonString, for: iapAuthInfoKey) - #else - try keychain.set(jsonString, key: iapAuthInfoKey) - #endif - } - - static func removeIAPAuthInfo() throws { - #if targetEnvironment(macCatalyst) - try store.remove(iapAuthInfoKey) - #else - try keychain.remove(iapAuthInfoKey) - #endif - } - static func getAuthMode() throws -> AuthMode? { #if targetEnvironment(macCatalyst) guard let rawValue = try store.get(authModeKey) else { return nil } diff --git a/flo/Shared/Utils/Constants.swift b/flo/Shared/Utils/Constants.swift index 0c5968c..dd37655 100644 --- a/flo/Shared/Utils/Constants.swift +++ b/flo/Shared/Utils/Constants.swift @@ -12,7 +12,6 @@ enum API { enum 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 index e48ca2e..0a29b68 100644 --- a/flo/Shared/Utils/IAPLoginView.swift +++ b/flo/Shared/Utils/IAPLoginView.swift @@ -15,10 +15,6 @@ 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 isSubmitButtonDisabled: Bool { serverUrl.isEmpty || isLoading @@ -52,9 +48,6 @@ struct IAPLoginView: View { IAPWebAuthView( serverURL: serverUrl, authViewModel: authViewModel, - customHeaderName: customHeaderName.isEmpty ? nil : customHeaderName, - customCookieName: customCookieName.isEmpty ? nil : customCookieName, - customUsernameCookie: customUsernameCookie.isEmpty ? nil : customUsernameCookie, onSuccess: { dismiss() }, @@ -97,9 +90,7 @@ struct IAPLoginView: View { placeholder: "https://your-iap-server.com", keyboardType: .URL ) - - advancedSettingsSection - + submitButton cancelButton @@ -133,71 +124,6 @@ struct IAPLoginView: View { .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) { diff --git a/flo/Shared/Utils/IAPWebAuthView.swift b/flo/Shared/Utils/IAPWebAuthView.swift index 09957d8..90c2587 100644 --- a/flo/Shared/Utils/IAPWebAuthView.swift +++ b/flo/Shared/Utils/IAPWebAuthView.swift @@ -11,9 +11,6 @@ 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 @@ -26,11 +23,8 @@ struct IAPWebAuthView: View { ZStack { IAPWebView( url: serverURL, - customHeaderName: customHeaderName, - customCookieName: customCookieName, - customUsernameCookie: customUsernameCookie, - onDataExtracted: { jwt, username, webView in - handleAuthentication(jwt: jwt, username: username, webView: webView) + onAuthExtracted: { userAuth, webView in + handleAuthentication(userAuth: userAuth, webView: webView) }, onError: { error in handleError(error) @@ -77,68 +71,82 @@ struct IAPWebAuthView: View { } } - private func handleAuthentication(jwt: String, username: String, webView: WKWebView) { + private func handleAuthentication(userAuth: UserAuth, webView: WKWebView) { webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in for cookie in cookies { HTTPCookieStorage.shared.setCookie(cookie) } - - self.completeOAuthLogin(jwt: jwt, username: username) + + self.completeOAuthLogin(userAuth: userAuth) } } - - private func completeOAuthLogin(jwt: String, username: String) { - let iapInfo = IAPAuthInfo(jwtAssertion: jwt, userEmail: username, userId: nil) - AuthService.shared.setIAPAuthInfo(iapInfo) + + private func completeOAuthLogin(userAuth: UserAuth) { 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.") - } + + verifySubsonicAccess(userAuth) { success, errorMessage in + DispatchQueue.main.async { + if success { + 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.authViewModel.persistAuthData(userAuth) - self.authViewModel.authMode = .iap - self.authViewModel.isLoggedIn = true - self.authViewModel.user = userAuth - - self.dismiss() - self.onSuccess() - } + self.handleError(errorMessage) } + } + } + } + + private func verifySubsonicAccess( + _ userAuth: UserAuth, completion: @escaping (Bool, String) -> Void + ) { + guard var components = URLComponents(string: "\(serverURL)/rest/ping") else { + completion(false, "Invalid server URL") + return + } + + components.queryItems = [ + URLQueryItem(name: "u", value: userAuth.username), + URLQueryItem(name: "t", value: userAuth.subsonicToken), + URLQueryItem(name: "s", value: userAuth.subsonicSalt), + URLQueryItem(name: "v", value: AppMeta.subsonicApiVersion), + URLQueryItem(name: "c", value: AppMeta.name), + URLQueryItem(name: "f", value: "json"), + ] + + guard let url = components.url else { + completion(false, "Invalid server URL") + return + } + + URLSession.shared.dataTask(with: URLRequest(url: url)) { data, response, error in + guard let httpResponse = response as? HTTPURLResponse else { + completion(false, "Could not verify authentication. Please check your network connection.") + return + } + + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let subsonicResponse = json["subsonic-response"] as? [String: Any], + let status = subsonicResponse["status"] as? String + else { + completion( + false, + "The server did not accept the session (HTTP \(httpResponse.statusCode)). Make sure Navidrome trusts your proxy (ExtAuth.TrustedSources / ReverseProxyWhitelist)." + ) + return + } + + if status == "ok" { + completion(true, "") } else { - DispatchQueue.main.async { - self.handleError("Could not verify authentication. Please check your network connection.") - } + let subsonicError = subsonicResponse["error"] as? [String: Any] + let message = subsonicError?["message"] as? String ?? "Something went wrong with IAP Authentication." + completion(false, message) } }.resume() } diff --git a/flo/Shared/Utils/IAPWebView.swift b/flo/Shared/Utils/IAPWebView.swift index 7a0f759..dcae587 100644 --- a/flo/Shared/Utils/IAPWebView.swift +++ b/flo/Shared/Utils/IAPWebView.swift @@ -10,223 +10,98 @@ import WebKit struct IAPWebView: UIViewRepresentable { let url: String - let customHeaderName: String? - let customCookieName: String? - let customUsernameCookie: String? - let onDataExtracted: (String, String, WKWebView) -> Void + let onAuthExtracted: (UserAuth, WKWebView) -> Void let onError: (String) -> Void - + func makeCoordinator() -> Coordinator { - Coordinator( - customHeaderName: customHeaderName, - customCookieName: customCookieName, - customUsernameCookie: customUsernameCookie, - onDataExtracted: onDataExtracted, - onError: onError - ) + Coordinator(onAuthExtracted: onAuthExtracted, 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 onAuthExtracted: (UserAuth, 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 = "" - + + private static let appConfigScript = """ + (function() { + var c = window.__APP_CONFIG__; + if (!c) { return null; } + return (typeof c === 'string') ? c : JSON.stringify(c); + })() + """ + init( - customHeaderName: String?, - customCookieName: String?, - customUsernameCookie: String?, - onDataExtracted: @escaping (String, String, WKWebView) -> Void, + onAuthExtracted: @escaping (UserAuth, WKWebView) -> Void, onError: @escaping (String) -> Void ) { - self.customHeaderName = customHeaderName - self.customCookieName = customCookieName - self.customUsernameCookie = customUsernameCookie - self.onDataExtracted = onDataExtracted + self.onAuthExtracted = onAuthExtracted 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 - } - + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + guard !hasExtractedData, let currentURL = webView.url?.absoluteString else { return } + + let normalizedCurrent = normalizeURL(currentURL) + let normalizedOriginal = normalizeURL(originalServerURL) + + guard normalizedCurrent.hasPrefix(normalizedOriginal) else { return } + + webView.evaluateJavaScript(Self.appConfigScript) { [weak self] result, _ in + guard let self = self, !self.hasExtractedData else { return } + + guard let jsonString = result as? String, + let jsonData = jsonString.data(using: .utf8), + let appConfig = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let authPayload = appConfig["auth"] as? [String: Any], + let authData = try? JSONSerialization.data(withJSONObject: authPayload), + let userAuth = try? JSONDecoder().decode(UserAuth.self, from: authData) + else { return } + + self.hasExtractedData = true DispatchQueue.main.async { - self.onDataExtracted(token, username, webView) + self.onAuthExtracted(userAuth, 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 { @@ -234,7 +109,7 @@ struct IAPWebView: UIViewRepresentable { } } } - + func webView( _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,