import CryptoKit import Foundation private let BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private let BML_CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private let BML_REDIRECT = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private let BML_APP_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)" private let BML_APP_VERSION = "2.1.44.348" private let BML_WEB_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" // Prevents URLSession from following redirects and captures Set-Cookie headers from // redirect responses. The 302 response is only fully accessible here, before completionHandler(nil) // is called — by the time data(for:delegate:) returns, the redirect response headers may be gone. // // Also stores lastRedirectLocation so callers can reliably read the Location header even when // the async data(for:) return value loses it after redirect cancellation. private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate { var onRedirectResponse: (@Sendable (HTTPURLResponse) -> Void)? // Written synchronously inside willPerformHTTPRedirection before completionHandler(nil); // read by BmlLoginFlow after data(for:) returns — sequential by design, no data race. var lastRedirectLocation = "" func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void ) { lastRedirectLocation = response.value(forHTTPHeaderField: "Location") ?? "" onRedirectResponse?(response) completionHandler(nil) } } final class BmlLoginFlow { private let noRedirect = NoRedirectDelegate() // Ephemeral session with automatic cookie handling. // noRedirect.onRedirectResponse feeds Set-Cookie headers from every 302 response // into forceCookies() — the only reliable place to see redirect response headers, // since data(for:delegate:) receives a stripped response after the redirect is cancelled. // Declared as `let` (not lazy) to avoid @MainActor inference on the accessing methods. private let webSession: URLSession init() { let cfg = URLSessionConfiguration.ephemeral cfg.timeoutIntervalForRequest = 30 webSession = URLSession(configuration: cfg, delegate: noRedirect, delegateQueue: nil) noRedirect.onRedirectResponse = { [weak self] redirectResp in self?.forceCookies(from: redirectResp) self?.captureRedirectCookies(from: redirectResp) } } // Extra store for cookies captured from redirect responses. // Keyed by name; injected explicitly into the authorize request header. private var redirectCookieStore: [String: String] = [:] private var codeVerifier = "" private var codeChallenge = "" private var deviceId = "" private(set) var lastProfiles: [BmlProfile] = [] // MARK: - Login func login(username: String, password: String, otpSeed: String) async throws -> [BmlProfile] { codeVerifier = generateCodeVerifier() codeChallenge = generateCodeChallenge(codeVerifier) deviceId = generateDeviceId() // Step 1: GET /web/login — seeds XSRF-TOKEN + blaze_session cookies try await webGet("\(BML_BASE_URL)/web/login") guard let xsrf = xsrfToken() else { throw BmlError.loginFailed("Could not fetch login page") } // Step 2: POST credentials let credBody = try jsonBody(["username": username, "password": password, "code": ""]) let credResp = try await webPost("\(BML_BASE_URL)/web/login", body: credBody, xsrf: xsrf) guard credResp.status == 302 else { throw BmlError.loginFailed("Login failed — check your username/password") } // Step 3: GET 2FA page — follow any canonical redirect so we land on the actual page, // which sets a fresh XSRF-TOKEN cookie needed for the OTP POST. let twoFaGetResp = try await webGet("\(BML_BASE_URL)/web/login/2fa") if twoFaGetResp.status == 302 { let twoFaLoc = twoFaGetResp.location.isEmpty ? noRedirect.lastRedirectLocation : twoFaGetResp.location if !twoFaLoc.isEmpty { let twoFaUrl = twoFaLoc.hasPrefix("http") ? twoFaLoc : "https://www.bankofmaldives.com.mv\(twoFaLoc)" try await webGet(twoFaUrl) } } let xsrf2 = xsrfToken() ?? xsrf // Step 4: POST TOTP — try current window, then ±1 windows to handle device clock skew. // A successful 2FA POST redirects somewhere other than /web/login (e.g. /web/profile). // A failed OTP redirects back to /web/login or /web/login/2fa. // Re-fetch XSRF before each attempt in case the server rotates it on a failed try. var twoFaSucceeded = false var lastOtp = "" var lastRedirectLoc = "" for timeStep in [0, -1, 1] { // Refresh XSRF before each attempt — server may invalidate it after a failed POST. let freshXsrf = xsrfToken() ?? xsrf2 let otp = Totp.generate(otpSeed, timeStep: timeStep) lastOtp = otp let body = try jsonBody(["code": otp, "channel": "authenticator"]) let resp = try await webPost("\(BML_BASE_URL)/web/login/2fa", body: body, xsrf: freshXsrf) let loc = resp.location.isEmpty ? noRedirect.lastRedirectLocation : resp.location lastRedirectLoc = loc if resp.status == 302 && !loc.contains("/web/login") { twoFaSucceeded = true break } } guard twoFaSucceeded else { throw BmlError.otpFailed("OTP verification failed (code tried: \(lastOtp), server redirected to: \(lastRedirectLoc)). If the code matches your authenticator, the XSRF-TOKEN or session cookie may be invalid.") } // Step 5: GET /web/profile — 302 = single profile, 200 = picker. // For 302, follow the redirect (e.g. to /web/redirect) so the server-side OAuth // session state is established before we call /oauth/authorize. // NOTE: The async data(for:delegate:) API may not return the Location header on the // response object after redirect cancellation. We fall back to noRedirect.lastRedirectLocation // which is written synchronously inside willPerformHTTPRedirection before the task completes. let profileResp = try await webGet("\(BML_BASE_URL)/web/profile") if profileResp.status == 302 { let loc = profileResp.location.isEmpty ? noRedirect.lastRedirectLocation : profileResp.location if !loc.isEmpty { let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)" try await webGet(redirectUrl) } lastProfiles = [BmlProfile(profileId: username, name: "Personal", type: "Profile", profileType: "default", autoActivated: true)] } else { lastProfiles = parseProfiles(profileResp.body) } return lastProfiles } // MARK: - Profile activation func activateProfile(_ profile: BmlProfile, loginTag: String) async throws -> BmlActivationResult { if profile.autoActivated { let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId) return .success(session, accounts) } var req = makeWebRequest(url: "\(BML_BASE_URL)/web/profile/\(profile.profileId)") if let xsrf = xsrfToken() { req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN") } let (_, resp) = try await webSession.data(for: req, delegate: noRedirect) let http = resp as! HTTPURLResponse forceCookies(from: http) let loc = http.value(forHTTPHeaderField: "Location") ?? "" let needsBusinessOtp = http.statusCode == 302 && loc.contains("/web/profile/2fa/business") if needsBusinessOtp { let channels = try await fetchBusinessOtpChannels() return .needsBusinessOtp(channels) } else if http.statusCode == 409 || http.statusCode == 302 { // Follow the redirect to establish server-side OAuth session state if !loc.isEmpty { let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)" try await webGet(redirectUrl) } let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId) return .success(session, accounts) } else { throw BmlError.profileActivationFailed("HTTP \(http.statusCode)") } } func requestBusinessOtp(channel: String) async throws { guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired } let body = try jsonBody(["code": "", "channel": channel]) let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf) guard resp.status == 302 else { throw BmlError.loginFailed("Failed to request OTP (HTTP \(resp.status))") } } func submitBusinessOtp(channel: String, code: String, profile: BmlProfile, loginTag: String) async throws -> (BmlSession, [BankAccount]) { try await webGet("\(BML_BASE_URL)/web/profile/2fa/business") guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired } let body = try jsonBody(["code": code, "channel": channel]) let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf) let loc = resp.location if resp.status == 409 || (resp.status == 302 && loc.contains("/web/redirect")) { return try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId) } else if resp.status == 302 { throw BmlError.otpFailed("Invalid OTP — please try again") } else { throw BmlError.otpFailed("Business OTP failed (HTTP \(resp.status))") } } // MARK: - Token refresh func refreshSession(_ session: BmlSession) async throws -> BmlSession { var req = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!) req.httpMethod = "POST" req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent") let fields = [ ("grant_type", "refresh_token"), ("refresh_token", session.refreshToken), ("client_id", BML_CLIENT_ID), ("Device-ID", session.deviceId), ("User-Agent", BML_APP_UA), ("x-app-version", BML_APP_VERSION) ] req.httpBody = Data(fields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8) req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") let (data, _) = try await makeApiSession().data(for: req) guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let newAccess = (obj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else { throw BmlError.sessionExpired } let newRefresh = (obj["refresh_token"] as? String ?? "").isEmpty ? session.refreshToken : obj["refresh_token"] as! String let expiresIn = obj["expires_in"] as? Int64 ?? 0 let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0 return BmlSession(accessToken: newAccess, deviceId: session.deviceId, refreshToken: newRefresh, expiresAt: expiresAt) } // MARK: - OAuth + account fetch private func doOAuthAndFetchAccounts( loginTag: String, profileName: String, profileId: String ) async throws -> (BmlSession, [BankAccount]) { // Device-ID, User-Agent (APP UA), x-app-version are query params — same as Android. var comps = URLComponents(string: "\(BML_BASE_URL)/oauth/authorize")! comps.queryItems = [ .init(name: "redirect_uri", value: BML_REDIRECT), .init(name: "client_id", value: BML_CLIENT_ID), .init(name: "response_type", value: "code"), .init(name: "state", value: randomUrlSafe(16)), .init(name: "nonce", value: randomUrlSafe(12)), .init(name: "code_challenge", value: codeChallenge), .init(name: "code_challenge_method", value: "S256"), .init(name: "Device-ID", value: deviceId), .init(name: "User-Agent", value: BML_APP_UA), .init(name: "x-app-version", value: BML_APP_VERSION), ] // Sync any cookies captured from redirect responses into the session cookie storage. // This covers blaze_identity and any other cookies that forceCookies() may have missed // due to redirect responses being cancelled before URLSession auto-processes them. if let storage = webSession.configuration.httpCookieStorage { for (name, value) in redirectCookieStore where !name.hasPrefix("__REDIRECT__") { if let cookie = HTTPCookie(properties: [ .name: name, .value: value, .domain: "www.bankofmaldives.com.mv", .path: "/", .secure: "TRUE" ]) { storage.setCookie(cookie) } } } // Build authorize request with all session cookies (now including redirect-captured ones). var authorizeReq = makeWebRequest(url: comps.url!.absoluteString) let allCookies = webSession.configuration.httpCookieStorage?.cookies ?? [] let cookieDict: [String: String] = Dictionary( uniqueKeysWithValues: allCookies.map { ($0.name, $0.value) } ) if !cookieDict.isEmpty { authorizeReq.setValue( cookieDict.map { "\($0.key)=\($0.value)" }.joined(separator: "; "), forHTTPHeaderField: "Cookie" ) authorizeReq.httpShouldHandleCookies = false } let sentCookieNames = cookieDict.keys.joined(separator: ",") let redirectMarkers = redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.sorted() .compactMap { redirectCookieStore[$0] }.joined(separator: "→") let (_, authorizeRaw) = try await webSession.data(for: authorizeReq, delegate: noRedirect) let authorizeHttp = authorizeRaw as! HTTPURLResponse forceCookies(from: authorizeHttp) let loc = authorizeHttp.value(forHTTPHeaderField: "Location") ?? "" guard !loc.isEmpty, let urlComps = URLComponents(string: loc), let code = urlComps.queryItems?.first(where: { $0.name == "code" })?.value else { let rStore = redirectCookieStore.keys.filter { !$0.hasPrefix("__REDIRECT__") }.sorted().joined(separator: ",") throw BmlError.oauthFailed("OAuth authorize failed (HTTP \(authorizeHttp.statusCode), location: \(loc), cookies: [\(sentCookieNames)], redirectPaths: [\(redirectMarkers)], redirectStore: [\(rStore)])") } let tokenFields = [ ("Device-ID", deviceId), ("code", code), ("grant_type", "authorization_code"), ("User-Agent", BML_APP_UA), ("redirect_uri", BML_REDIRECT), ("code_verifier", codeVerifier), ("client_id", BML_CLIENT_ID), ("x-app-version", BML_APP_VERSION) ] var tokenReq = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!) tokenReq.httpMethod = "POST" tokenReq.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent") tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") tokenReq.httpBody = Data(tokenFields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8) let (tokenData, _) = try await webSession.data(for: tokenReq, delegate: noRedirect) guard let tokenObj = try? JSONSerialization.jsonObject(with: tokenData) as? [String: Any], let accessToken = (tokenObj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else { throw BmlError.oauthFailed("Token exchange failed") } let refreshToken = tokenObj["refresh_token"] as? String ?? "" let expiresIn = tokenObj["expires_in"] as? Int64 ?? 0 let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0 let session = BmlSession(accessToken: accessToken, deviceId: deviceId, refreshToken: refreshToken, expiresAt: expiresAt) let client = BmlAccountClient(session: session) let accounts = try await client.fetchAccounts(loginTag: loginTag, profileName: profileName, profileId: profileId) return (session, accounts) } // MARK: - Business OTP channels private func fetchBusinessOtpChannels() async throws -> [BmlOtpChannel] { let resp = try await webGet("\(BML_BASE_URL)/web/profile/2fa/business") return parseBusinessOtpChannels(resp.body) } // MARK: - Inertia.js parsing private func extractInertiaJson(_ html: String) -> [String: Any]? { guard let range = html.range(of: #"data-page="([^"]+)""#, options: .regularExpression) else { return nil } var escaped = String(html[range]) escaped = escaped.replacingOccurrences(of: #"data-page=""#, with: "").dropLast().description let unescaped = escaped .replacingOccurrences(of: """, with: "\"") .replacingOccurrences(of: "&", with: "&") .replacingOccurrences(of: "'", with: "'") .replacingOccurrences(of: "<", with: "<") .replacingOccurrences(of: ">", with: ">") return try? JSONSerialization.jsonObject(with: Data(unescaped.utf8)) as? [String: Any] } private func parseProfiles(_ html: String) -> [BmlProfile] { guard let root = extractInertiaJson(html), let props = root["props"] as? [String: Any], let profiles = props["profiles"] as? [[String: Any]] else { return [] } return profiles.compactMap { p in guard let profileObj = p["profile"] as? [String: Any] else { return nil } return BmlProfile( profileId: p["profile_id"] as? String ?? "", name: p["name"] as? String ?? "", type: p["type"] as? String ?? "", profileType: profileObj["profile_type"] as? String ?? "default", autoActivated: false ) } } private func parseBusinessOtpChannels(_ html: String) -> [BmlOtpChannel] { guard let root = extractInertiaJson(html), let props = root["props"] as? [String: Any], let channels = props["channels"] as? [[String: Any]] else { return [] } return channels.map { c in BmlOtpChannel( channel: c["channel"] as? String ?? "", description: c["description"] as? String ?? "", masked: c["masked"] as? String ?? "" ) } } // MARK: - Cookie helpers // Stores raw name=value pairs from redirect response cookies into redirectCookieStore. // Uses direct string parsing of allHeaderFields to bypass HTTPCookie parsing quirks. // Also records a __REDIRECT__{n} marker so we know if this was ever called at all. private func captureRedirectCookies(from response: HTTPURLResponse) { let path = response.url?.path ?? "nil" let location = response.value(forHTTPHeaderField: "Location") ?? "?" let markerKey = "__REDIRECT__\(redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.count)" redirectCookieStore[markerKey] = "\(path)→\(location)" // source path → redirect destination for (key, value) in response.allHeaderFields { guard let k = key.base as? String, k.lowercased() == "set-cookie", let v = value as? String else { continue } // URLSession may combine multiple Set-Cookie headers with "\n" for cookieLine in v.components(separatedBy: "\n") { guard let nameValuePart = cookieLine.split(separator: ";").first else { continue } let parts = nameValuePart.split(separator: "=", maxSplits: 1) guard parts.count == 2 else { continue } let name = parts[0].trimmingCharacters(in: .whitespaces) let val = String(parts[1]).trimmingCharacters(in: .whitespaces) redirectCookieStore[name] = val } } } // After every response, force-write Set-Cookie headers into the session's ephemeral // cookie storage. URLSession may skip automatic Set-Cookie processing for redirect // responses when the redirect is cancelled via NoRedirectDelegate.completionHandler(nil). // // IMPORTANT: allHeaderFields is a [AnyHashable: Any] dict — if the server sends multiple // Set-Cookie headers, URLSession joins them with "\n" into a single entry. Building a // plain [String: String] and passing it to HTTPCookie.cookies() would only parse the // first cookie. We split on "\n" and call HTTPCookie.cookies() once per line instead. private func forceCookies(from response: HTTPURLResponse) { guard let storage = webSession.configuration.httpCookieStorage, let url = response.url else { return } for (key, value) in response.allHeaderFields { guard let k = key.base as? String, k.caseInsensitiveCompare("set-cookie") == .orderedSame, let v = value as? String else { continue } for line in v.components(separatedBy: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { continue } let cookies = HTTPCookie.cookies( withResponseHeaderFields: ["Set-Cookie": trimmed], for: url ) cookies.forEach { storage.setCookie($0) } } } } private func xsrfToken() -> String? { guard let url = URL(string: BML_BASE_URL) else { return nil } return webSession.configuration.httpCookieStorage? .cookies(for: url)?.first { $0.name == "XSRF-TOKEN" }?.value } // MARK: - HTTP helpers @discardableResult private func webGet(_ urlString: String) async throws -> (status: Int, body: String, location: String) { let req = makeWebRequest(url: urlString) let (data, resp) = try await webSession.data(for: req, delegate: noRedirect) let http = resp as! HTTPURLResponse forceCookies(from: http) return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "") } private func webPost(_ urlString: String, body: Data, xsrf: String) async throws -> (status: Int, body: String, location: String) { var req = makeWebRequest(url: urlString) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN") req.httpBody = body let (data, resp) = try await webSession.data(for: req, delegate: noRedirect) let http = resp as! HTTPURLResponse forceCookies(from: http) return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "") } private func makeWebRequest(url: String) -> URLRequest { var req = URLRequest(url: URL(string: url)!) req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent") return req } private func jsonBody(_ dict: [String: String]) throws -> Data { try JSONSerialization.data(withJSONObject: dict) } private func makeApiSession() -> URLSession { let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 30 return URLSession(configuration: cfg) } // MARK: - PKCE / crypto private func generateCodeVerifier() -> String { var bytes = [UInt8](repeating: 0, count: 72) _ = SecRandomCopyBytes(kSecRandomDefault, 72, &bytes) return Data(bytes).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } private func generateCodeChallenge(_ verifier: String) -> String { let hash = SHA256.hash(data: Data(verifier.utf8)) return Data(hash).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } private func randomUrlSafe(_ byteCount: Int) -> String { var bytes = [UInt8](repeating: 0, count: byteCount) _ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) return Data(bytes).base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } private func generateDeviceId() -> String { var bytes = [UInt8](repeating: 0, count: 8) _ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes) return bytes.map { String(format: "%02x", $0) }.joined() } }