a neat project
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add switch protection and new layout

+323 -135
+130 -97
README.md
··· 4 4 5 5 The canonical repo for this is hosted on tangled over at [`dunkirk.sh/huntington`](https://tangled.org/dunkirk.sh/huntington) 6 6 7 + <p align="center"> 8 + <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" /> 9 + </p> 10 + 7 11 ## API 8 12 9 - All API traffic goes through `m.huntington.com`. There are two namespaces: 13 + All traffic goes to `m.huntington.com` under two namespaces: 10 14 11 - - `/api/mobile-authentication/1.8/` — auth flow (login, OTP, device registration) 12 - - `/api/mobile-customer-accounts/1.11/` — account data (balances, transactions) 15 + - `mobile-authentication/1.8` — login, OTP, device registration 16 + - `mobile-customer-accounts/1.11` — accounts, balances, transactions 13 17 14 - ### Authentication 18 + ### Session model 15 19 16 - Every authenticated request requires two things: 20 + Every authenticated request needs two things: 17 21 18 - - Session cookies `PD-ID` and `PD-S-SESSION-ID` (set by IBM Security Verify / DataPower after login) 19 - - An `x-auth-receipt` header — a short-lived rolling token issued by the auth layer 22 + - **Cookies** — `PD-ID` and `PD-S-SESSION-ID`, set by IBM Security Verify after login 23 + - **`x-auth-receipt`** — a rolling token that the server rotates on every response; using a stale one yields a 401 20 24 21 - The receipt **rotates on every response**: each API call returns a new `x-auth-receipt` that must be used for the next call. Using a stale receipt yields a 401. 25 + All requests also carry: 22 26 23 - #### Headers (all requests) 27 + | Header | Value | 28 + | --- | --- | 29 + | `x-channel` | `MOBILE` | 30 + | `x-context-id` | lowercase UUID, generated once per session | 31 + | `x-auth-receipt` | current receipt token | 32 + | `user-agent` | `HuntingtonMobileBankingIOS/6.74.115` | 24 33 25 - | Header | Value | 26 - | ---------------- | -------------------------------------- | 27 - | `x-channel` | `MOBILE` | 28 - | `x-context-id` | UUID generated per session (lowercase) | 29 - | `x-auth-receipt` | Rolling receipt token | 30 - | `user-agent` | `HuntingtonMobileBankingIOS/6.74.115` | 34 + ### Login 31 35 32 - #### Login flow (new device / OTP required) 36 + #### Step 1 — establish session 33 37 34 38 ``` 35 39 POST /api/mobile-authentication/1.8/mobile-init ··· 37 41 → 201 38 42 39 43 POST /pkmslogin.form 40 - body: login-form-type=pwd&userName=...&password=... 41 - → 302 (sets PD-ID, PD-S-SESSION-ID cookies) 44 + content-type: application/x-www-form-urlencoded 45 + body: login-form-type=pwd&userName=…&password=… 46 + → 302 (sets PD-ID, PD-S-SESSION-ID cookies) 42 47 43 48 GET /api/mobile-authentication/1.8/contexts/{ctx}/authentication-receipt 44 49 ?olbLoginId={username}&loginType=USER_PASS 45 - → 200, x-auth-receipt header, body: { customerId } 50 + → 200 x-auth-receipt: <token> 51 + body: { customerId } 52 + ``` 53 + 54 + #### Step 2 — device check 46 55 56 + ``` 47 57 POST /api/mobile-authentication/1.8/contexts/{ctx}/second-factors 48 - body: { fingerprint, olbLoginId, policy: "ANDROID", profile: "MOBILE", 49 - deviceId, token, fraudSessionId, loginType, flowId } 50 - → 201, body: { secondFactorId, passed, registrationData } 51 - # passed=true → skip to activate-customer (trusted device) 52 - # passed=false → OTP required 58 + body: { 59 + olbLoginId, policy: "ANDROID", profile: "MOBILE", 60 + deviceId, token, ← persisted device identity; empty string on first run 61 + fraudSessionId, ← random UUID, no dashes 62 + loginType: "USER_PASS", flowId: "", 63 + fingerprint: { attributes: { os, osname, numberOfProcessors, localeName, rooted, appVersion } } 64 + } 65 + → 201 body: { secondFactorId, passed, registrationData: { token } } 66 + ``` 53 67 54 - GET /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/otp/delivery-options 55 - → 200, body: { phoneNumbers: [{id, value}], emailAddresses: [{id, value}] } 68 + `passed: true` means the device is trusted — skip to [activate](#step-4--activate). `passed: false` means OTP is required. 69 + 70 + > **Note:** `policy` must be `"ANDROID"`. The `"IOS"` value triggers a server bug that causes 500s on `otp/status`. 71 + 72 + #### Step 3 — OTP (new device only) 73 + 74 + ``` 75 + GET /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/otp/delivery-options 76 + → 200 body: { phoneNumbers: [{id, value}], emailAddresses: [{id, value}] } 56 77 57 - PUT /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/otp/delivery-options/{optionId} 78 + PUT /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/otp/delivery-options/{optionId} 58 79 body: {} 59 - → 200 (sends OTP to selected phone/email) 80 + → 200 (triggers SMS or email with code) 60 81 61 - PUT /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/otp/status 82 + PUT /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/otp/status 62 83 body: { otpValue: "123456", flowId: "" } 63 - → 200, body: { passed: true }, rotates x-auth-receipt 84 + → 200 body: { passed: true } x-auth-receipt: <rotated> 85 + 86 + GET /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/v2/ia-challenge-question 87 + → 200 body: {} x-auth-receipt: <rotated again> 88 + ``` 89 + 90 + The receipt rotates twice through OTP verification — `otp/status` rotates it once, `ia-challenge-question` rotates it again. Use the receipt from `ia-challenge-question` for the activate call. 64 91 65 - GET /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/v2/ia-challenge-question 66 - → 200, body: {} (no challenge), rotates x-auth-receipt again 92 + #### Step 4 — activate 67 93 94 + ``` 68 95 POST /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/customers 69 96 body: { secondFactorId, fraudSessionId } 70 - → 201, body: { customer: { customerId, name, ... } } 97 + → 201 body: { customer: { customerId, customerType, name, displayName } } 98 + ``` 71 99 100 + #### Step 5 — register device (background, OTP path only) 101 + 102 + ``` 72 103 POST /api/mobile-authentication/1.8/contexts/{ctx}/second-factors/{sfId}/registrations 73 104 body: { deviceName: "iPhone" } 74 - → 201, body: { registrationData: { token } } 75 - # Save token — used in future second-factors calls to skip OTP 105 + → 201 body: { registrationData: { deviceId, token } } 76 106 ``` 77 107 78 - #### Login flow (trusted device, `passed=true`) 108 + Save the `token` — pass it in `second-factors` on future logins to skip OTP. 79 109 80 - Same as above through `second-factors`, then jump straight to `activate-customer`. No OTP, no `ia-challenge-question`. 110 + ### Accounts 81 111 82 - ### Account data 83 - 112 + ``` 113 + GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/accounts?refresh=false 114 + → 200 body: { 115 + groups: [{ 116 + accountCategory, ← e.g. "CASH" 117 + accounts: [{ 118 + accountId, accountType, nickName, 119 + availableBalance, currentBalance, 120 + maskedAccountNumber, routingNumber 121 + }] 122 + }] 123 + } 84 124 ``` 85 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/accounts 86 - ?refresh=false 87 - → 200, body: { groups: [{ accountCategory, accounts: [{ accountId, accountType, 88 - nickName, availableBalance, currentBalance, 89 - maskedAccountNumber, routingNumber }] }] } 90 125 91 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/last-login 92 - → 200, body: { lastLogin: "2026-03-31T20:12:45.043Z" } 126 + ### Customer info 93 127 94 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/customer-contacts 95 - → 200, body: { baseContacts: { postalAddress, phoneNumbers, emailId }, 96 - alertContacts: { alertEmails, alertPhones } } 128 + ``` 129 + GET …/customers/{customerId}/last-login 130 + → 200 body: { lastLogin: "2026-03-31T20:12:45.043Z" } 97 131 98 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/customer-custom-attribute 99 - → 200, body: feature flag map (UI state, onboarding flags, badge counts) 132 + GET …/customers/{customerId}/customer-contacts 133 + → 200 body: { 134 + baseContacts: { postalAddress, phoneNumbers: { cellPhone }, emailId }, 135 + alertContacts: { alertEmails, alertPhones } 136 + } 100 137 ``` 101 138 102 139 ### Transactions 103 140 104 - There are three transaction endpoints per account, each returning a different slice: 141 + Three endpoints per account, each a different slice: 105 142 106 143 ``` 107 - # Combined posted + pending (most recent page, no date filter) 108 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/deposits/{accountId}/transactions 109 - → 200, body: { items: [...] } 110 - # items have transactionCategory: "history" or "pending" 111 - # Returns a cursor in the last item for pagination (see below) 112 - 113 - # Paginate further back using a cursor from the previous response 114 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/deposits/{accountId}/transactions 115 - ?textRecordControl={cursor} 116 - → 200, body: { items: [...] } 144 + # Recent posted + pending transactions (paginated) 145 + GET …/deposits/{accountId}/transactions 146 + GET …/deposits/{accountId}/transactions?textRecordControl={cursor} 147 + → 200 body: { items: [...] } 148 + # transactionCategory: "history" | "pending" 117 149 118 - # Posted transactions only (savings/interest accounts use this endpoint) 119 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/deposits/{accountId}/transaction-history 120 - → 200, body: { items: [...] } 150 + # Posted transactions only (savings/interest accounts) 151 + GET …/deposits/{accountId}/transaction-history 152 + → 200 body: { items: [...] } 121 153 122 154 # Pending transactions only 123 - GET /api/mobile-customer-accounts/1.11/contexts/{ctx}/customers/{customerId}/deposits/{accountId}/v2/pending-transactions 124 - → 200, body: { items: [...], inProcessTransactionExists, overdraftIndicator, 125 - idaResponse: { totalIdaAmount, defaultIdaAmount, ... } } 155 + GET …/deposits/{accountId}/v2/pending-transactions 156 + → 200 body: { 157 + items: [...], 158 + inProcessTransactionExists, 159 + overdraftIndicator, 160 + idaResponse: { totalIdaAmount, defaultIdaAmount, remainingAmount, … } 161 + } 126 162 ``` 127 163 128 - The `textRecordControl` cursor is an opaque string embedded in the transaction data — it encodes account number, account type, and date boundaries for the next page. Pass it verbatim to fetch the next batch. 164 + The `textRecordControl` cursor is an opaque string returned by the server — it encodes account number, type, and date range for the next page. Pass it verbatim to page back through history. 129 165 130 - #### Posted transaction fields 166 + #### Transaction fields 131 167 132 - | Field | Description | 133 - | -------------------------------- | ----------------------------------------- | 134 - | `transactionCategory` | `"history"` or `"pending"` | 135 - | `transactionAmount` | Amount as string (always positive) | 136 - | `runningBalance` | Balance after this transaction | 137 - | `postedDate` | `YYYY-MM-DD` | 138 - | `payeeName` | Merchant/payee name (posted) | 139 - | `transactionTypeDescription` | e.g. `"Direct Deposit"`, `"Transfer"` | 140 - | `imageId` | Opaque ID (used as stable transaction ID) | 141 - | `referenceNumber` | Bank reference number | 142 - | `memos` | Array of memo strings | 143 - | `merchantCity` / `merchantState` | Card transaction location | 144 - | `oysa.isZelleTransaction` | Whether this is a Zelle transfer | 168 + Posted (`transactionCategory: "history"`): 145 169 146 - #### Pending transaction fields 170 + | Field | Notes | 171 + | --- | --- | 172 + | `transactionAmount` | Always positive (string) | 173 + | `runningBalance` | Balance after this transaction (string) | 174 + | `postedDate` | `YYYY-MM-DD` | 175 + | `payeeName` | Merchant/payee name | 176 + | `transactionTypeDescription` | e.g. `"Direct Deposit"`, `"Transfer"` | 177 + | `imageId` | Stable transaction ID | 178 + | `memos` | Array of memo strings | 179 + | `merchantCity` / `merchantState` | Card transaction location | 180 + | `oysa.isZelleTransaction` | Whether this is a Zelle transfer | 147 181 148 - | Field | Description | 149 - | ----------------------------------------- | ---------------------- | 150 - | `transactionType` / `transactionTypeDesc` | Type description | 151 - | `totalTransactionDebitAmount` | Debit amount (string) | 152 - | `postedTransactionCreditAmount` | Credit amount (string) | 153 - | `memo` | Memo string | 182 + Pending (`transactionCategory: "pending"`): 154 183 155 - ### Notes 184 + | Field | Notes | 185 + | --- | --- | 186 + | `transactionType` / `transactionTypeDesc` | Type description | 187 + | `totalTransactionDebitAmount` | Debit amount (string) | 188 + | `postedTransactionCreditAmount` | Credit amount (string) | 189 + | `memo` | Memo string | 156 190 157 - - The `x-context-id` UUID must be **lowercase** — uppercase UUIDs cause 500 errors on `otp/status` 158 - - `second-factors` must use `policy: "ANDROID"` — the `"IOS"` policy path has a server-side bug that causes 500 on `otp/status` 159 - - `pkmslogin.form` uses HTTP/2 and occasionally resets the connection (-1005); retry with a fresh context ID 160 - - Session state (context ID, receipt, customer ID, cookies) can be persisted and reused across app launches — validate by hitting the accounts endpoint on startup 161 - - The transactions endpoint returns a rolling window of recent items (not a fixed 30-day window); use `textRecordControl` pagination to go further back 162 - - The `transactions` endpoint mixes posted and pending; `transaction-history` and `v2/pending-transactions` split them out separately 191 + ### Gotchas 192 + 193 + - `x-context-id` must be **lowercase** — uppercase UUIDs cause 500s on `otp/status` 194 + - `pkmslogin.form` occasionally resets the HTTP/2 connection (NSURLError -1005); retry with a fresh context ID 195 + - Session state (context ID, receipt, customer ID, cookies) survives app restarts — validate on launch by hitting the accounts endpoint 163 196 164 197 <p align="center"> 165 198 <img src="https://raw.githubusercontent.com/taciturnaxolotl/carriage/main/.github/images/line-break.svg" />
+181 -32
huntington/ContentView.swift
··· 1 1 import SwiftUI 2 + import LocalAuthentication 2 3 3 4 struct ContentView: View { 4 5 @State private var session = HuntingtonSession() ··· 7 8 @State private var showLogin = false 8 9 @State private var isLoading = false 9 10 @State private var errorMessage: String? 11 + @State private var isInitializing = true 12 + @State private var biometricLocked = false 13 + @State private var backgroundedAt: Date? 14 + private let lockTimeout: TimeInterval = 15 * 60 10 15 11 16 private var client: HuntingtonClient { HuntingtonClient(session: session) } 12 17 13 18 var body: some View { 19 + TabView { 20 + Tab("Home", systemImage: "house") { 21 + HomeTab(accounts: accounts, transactions: transactions, 22 + isLoading: isLoading, errorMessage: errorMessage, 23 + isAuthenticated: session.isAuthenticated, 24 + onRefresh: loadData, onSignIn: { showLogin = true }) 25 + } 26 + Tab("Zelle", systemImage: "arrow.left.arrow.right") { 27 + ZelleTab() 28 + } 29 + Tab("Profile", systemImage: "person.circle") { 30 + ProfileTab(accounts: accounts, displayName: session.displayName, 31 + onSignOut: { session.signOut() }) 32 + } 33 + } 34 + .overlay { 35 + if isInitializing || biometricLocked || scenePhase != .active { 36 + ZStack { 37 + Color(.systemBackground).ignoresSafeArea() 38 + NeoWordmark(font: .title.bold()) 39 + } 40 + .onTapGesture { 41 + if biometricLocked { Task { await unlockWithBiometrics() } } 42 + } 43 + } 44 + } 45 + .sheet(isPresented: $showLogin) { 46 + LoginView(session: session) 47 + } 48 + .task { 49 + await session.initialize() 50 + isInitializing = false 51 + if session.isAuthenticated { 52 + await unlockWithBiometrics() 53 + } else { 54 + showLogin = true 55 + } 56 + } 57 + .onChange(of: session.isAuthenticated) { _, authenticated in 58 + if authenticated { Task { await loadData() } } 59 + } 60 + .onChange(of: scenePhase) { _, phase in 61 + switch phase { 62 + case .background: 63 + backgroundedAt = Date() 64 + case .active: 65 + if let t = backgroundedAt, Date().timeIntervalSince(t) >= lockTimeout { 66 + biometricLocked = true 67 + Task { await unlockWithBiometrics() } 68 + } 69 + backgroundedAt = nil 70 + default: 71 + break 72 + } 73 + } 74 + } 75 + 76 + @Environment(\.scenePhase) private var scenePhase 77 + 78 + private func unlockWithBiometrics() async { 79 + let context = LAContext() 80 + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { 81 + await loadData() 82 + return 83 + } 84 + biometricLocked = true 85 + do { 86 + let ok = try await context.evaluatePolicy( 87 + .deviceOwnerAuthenticationWithBiometrics, 88 + localizedReason: "Authenticate to access your accounts") 89 + if ok { 90 + biometricLocked = false 91 + await loadData() 92 + } 93 + } catch { 94 + // Biometrics failed or cancelled — let them try again by tapping 95 + } 96 + } 97 + 98 + private func loadData() async { 99 + isLoading = true 100 + errorMessage = nil 101 + defer { isLoading = false } 102 + do { 103 + accounts = try await client.getAccounts() 104 + transactions = try await client.getRecentTransactions(accounts: accounts) 105 + } catch { 106 + errorMessage = error.localizedDescription 107 + } 108 + } 109 + } 110 + 111 + // MARK: - Tabs 112 + 113 + struct HomeTab: View { 114 + let accounts: [Account] 115 + let transactions: [Transaction] 116 + let isLoading: Bool 117 + let errorMessage: String? 118 + let isAuthenticated: Bool 119 + let onRefresh: () async -> Void 120 + let onSignIn: () -> Void 121 + 122 + var body: some View { 14 123 NavigationStack { 15 124 Group { 16 125 if isLoading { 17 126 ProgressView("Loading…") 18 127 } else if let error = errorMessage { 19 128 ContentUnavailableView(error, systemImage: "exclamationmark.triangle") 20 - } else if !session.isAuthenticated { 129 + } else if !isAuthenticated { 21 130 ContentUnavailableView("Not Signed In", systemImage: "lock", 22 131 description: Text("Tap Sign In to connect your Huntington account.")) 23 132 } else { ··· 32 141 } 33 142 } 34 143 if !transactions.isEmpty { 35 - Section("Last 30 Days") { 144 + Section("Recent") { 36 145 ForEach(transactions) { tx in 37 146 NavigationLink(destination: TransactionDetailView(transaction: tx, accounts: accounts)) { 38 147 TransactionRow(transaction: tx) ··· 41 150 } 42 151 } 43 152 } 44 - .refreshable { await loadData() } 153 + .refreshable { await onRefresh() } 45 154 } 46 155 } 156 + .navigationBarTitleDisplayMode(.inline) 47 157 .toolbar { 48 - ToolbarItem(placement: .principal) { 49 - NeoWordmark() 50 - } 51 - ToolbarItem(placement: .primaryAction) { 52 - if session.isAuthenticated { 53 - Button("Sign Out", role: .destructive) { session.signOut() } 54 - } else { 55 - Button("Sign In") { showLogin = true } 158 + ToolbarItem(placement: .principal) { NeoWordmark() } 159 + if !isAuthenticated { 160 + ToolbarItem(placement: .primaryAction) { 161 + Button("Sign In", action: onSignIn) 56 162 } 57 163 } 58 164 } 59 165 } 60 - .sheet(isPresented: $showLogin) { 61 - LoginView(session: session) 62 - } 63 - .task { 64 - await session.initialize() 65 - if session.isAuthenticated { 66 - await loadData() 67 - } else { 68 - showLogin = true 166 + } 167 + } 168 + 169 + struct ZelleTab: View { 170 + var body: some View { 171 + NavigationStack { 172 + ContentUnavailableView("Zelle", systemImage: "arrow.left.arrow.right", 173 + description: Text("Zelle support coming soon.")) 174 + .navigationBarTitleDisplayMode(.inline) 175 + .toolbar { 176 + ToolbarItem(placement: .principal) { NeoWordmark() } 69 177 } 70 - } 71 - .onChange(of: session.isAuthenticated) { _, authenticated in 72 - if authenticated { Task { await loadData() } } 73 178 } 74 179 } 180 + } 75 181 76 - private func loadData() async { 77 - isLoading = true 78 - errorMessage = nil 79 - defer { isLoading = false } 80 - do { 81 - accounts = try await client.getAccounts() 82 - transactions = try await client.getRecentTransactions(accounts: accounts) 83 - } catch { 84 - errorMessage = error.localizedDescription 182 + struct ProfileTab: View { 183 + let accounts: [Account] 184 + let displayName: String 185 + let onSignOut: () -> Void 186 + 187 + var body: some View { 188 + NavigationStack { 189 + List { 190 + if !displayName.isEmpty { 191 + Section { 192 + HStack { 193 + Spacer() 194 + VStack(spacing: 4) { 195 + Image(systemName: "person.circle.fill") 196 + .font(.system(size: 56)) 197 + .foregroundStyle(.secondary) 198 + Text(displayName.capitalized) 199 + .font(.title3.weight(.semibold)) 200 + } 201 + .padding(.vertical, 8) 202 + Spacer() 203 + } 204 + .listRowBackground(Color.clear) 205 + } 206 + } 207 + 208 + if !accounts.isEmpty { 209 + Section("Accounts") { 210 + ForEach(accounts) { acct in 211 + HStack { 212 + VStack(alignment: .leading, spacing: 2) { 213 + Text(acct.alias).fontWeight(.medium) 214 + Text(acct.accountType).font(.caption).foregroundStyle(.secondary) 215 + } 216 + Spacer() 217 + VStack(alignment: .trailing, spacing: 2) { 218 + Text(acct.number).font(.caption).foregroundStyle(.secondary) 219 + Text("Routing: \(acct.routingNumber)").font(.caption2).foregroundStyle(.tertiary) 220 + } 221 + } 222 + } 223 + } 224 + } 225 + 226 + Section("App") { 227 + Button("Sign Out", role: .destructive, action: onSignOut) 228 + } 229 + } 230 + .navigationBarTitleDisplayMode(.inline) 231 + .toolbar { 232 + ToolbarItem(placement: .principal) { NeoWordmark() } 233 + } 85 234 } 86 235 } 87 236 }
+12 -6
huntington/HuntingtonSession.swift
··· 23 23 private(set) var contextId = "" 24 24 private(set) var authReceipt = "" 25 25 private(set) var customerId = "" 26 + private(set) var displayName = "" 26 27 27 28 private let base = "https://m.huntington.com" 28 29 private let stateKey = "huntington_auth_state_v2" ··· 88 89 89 90 try await seedCookies() 90 91 var lastError: Error? 91 - for attempt in 1...3 { 92 + for _ in 1...3 { 92 93 do { 93 94 try await mobileInit(contextId: ctx) 94 95 try await pkmsLogin(username: username, password: password, contextId: ctx) ··· 299 300 var req = agwRequest("POST", path, ctx: contextId, receipt: authReceipt) 300 301 req.setValue("application/json; charset=utf-8", forHTTPHeaderField: "content-type") 301 302 req.httpBody = try? JSONSerialization.data(withJSONObject: ["deviceName": "iPhone"]) 302 - guard let (data, resp) = try? await session.data(for: req) else { return } 303 - let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 303 + guard let (data, _) = try? await session.data(for: req) else { return } 304 304 if let reg = try? JSONDecoder().decode(RegistrationResponse.self, from: data), 305 305 let token = reg.registrationData?.token, !token.isEmpty { 306 306 UserDefaults.standard.set(token, forKey: "huntington_device_token") ··· 312 312 let path = "/api/mobile-authentication/1.8/contexts/\(contextId)/second-factors/\(secondFactorId)/v2/ia-challenge-question" 313 313 let req = agwRequest("GET", path, ctx: contextId, receipt: authReceipt) 314 314 let (_, resp) = try await session.data(for: req) 315 - let http = resp as? HTTPURLResponse 316 - let status = http?.statusCode ?? 0 317 - return http?.value(forHTTPHeaderField: "x-auth-receipt") ?? authReceipt 315 + return (resp as? HTTPURLResponse)?.value(forHTTPHeaderField: "x-auth-receipt") ?? authReceipt 318 316 } 319 317 320 318 private func activateCustomer(contextId: String, authReceipt: String, ··· 329 327 guard status == 200 || status == 201 else { 330 328 print("[auth] activate-customer failed (\(status)): \(String(data: data, encoding: .utf8) ?? "")") 331 329 throw HuntingtonError.authFailed("Could not activate session (\(status))") 330 + } 331 + if let result = try? JSONDecoder().decode(ActivateCustomerResponse.self, from: data) { 332 + displayName = result.customer.displayName 332 333 } 333 334 } 334 335 ··· 491 492 492 493 private struct OTPStatusResponse: Decodable { 493 494 let passed: Bool 495 + } 496 + 497 + private struct ActivateCustomerResponse: Decodable { 498 + struct Customer: Decodable { let displayName: String } 499 + let customer: Customer 494 500 } 495 501 496 502 private struct RegistrationResponse: Decodable {