Files
Thijooree-iOS/Thijooree iOS/API/BML/BmlLoginFlow.swift
T

505 lines
26 KiB
Swift

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: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", 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()
}
}