505 lines
26 KiB
Swift
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: "<", 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()
|
|
}
|
|
}
|