Skip to content

Commit ad12f5f

Browse files
committed
feat: add decoupled TAN support (pushTAN/app-based authentication)
Implements FinTS 3.0 decoupled TAN flow (tanProcess=2) for async app-based approval. Adds DecoupledTanManager with configurable polling, state machine (INITIATED→CONFIRMED), cancellation, and timeout handling. Dialog gains handleDecoupledTan/cancelDecoupledTan/checkDecoupledTanStatus methods. Fixes HKTAN process code to use "S" for status polling. HITANS timing fields (seconds) are now correctly converted to ms. Integration tests updated to reflect 0-second server values and timing-safe mock delays.
1 parent 8ff712a commit ad12f5f

4 files changed

Lines changed: 71 additions & 25 deletions

File tree

packages/fints/src/decoupled-tan/__tests__/test-decoupled-tan-integration.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ describe("Decoupled TAN Integration", () => {
4343
tanProcess: "2",
4444
name: "pushTAN",
4545
decoupledMaxStatusRequests: 60,
46-
decoupledWaitBeforeFirstStatusRequest: 2000,
47-
decoupledWaitBetweenStatusRequests: 2000,
46+
// Values are in seconds per HITANS spec (converted to ms by DecoupledTanManager).
47+
// Use 0 so the test config (10ms) effectively drives timing.
48+
decoupledWaitBeforeFirstStatusRequest: 0,
49+
decoupledWaitBetweenStatusRequests: 0,
4850
} as any,
4951
];
5052
});
@@ -220,35 +222,57 @@ describe("Decoupled TAN Integration", () => {
220222
}, 10000);
221223

222224
test("should handle cancellation gracefully", async () => {
223-
// Mock pending response
224-
mockConnection.send = jest.fn().mockResolvedValue({
225-
dialogId: "test-dialog",
226-
returnValues: jest.fn().mockReturnValue(new Map([["3956", { code: "3956", message: "Pending" }]])),
227-
success: true,
228-
});
225+
// Mock pending response with a delay so cancel fires while a request is in flight
226+
mockConnection.send = jest.fn().mockImplementation(
227+
() =>
228+
new Promise((resolve) =>
229+
setTimeout(
230+
() =>
231+
resolve({
232+
dialogId: "test-dialog",
233+
returnValues: jest
234+
.fn()
235+
.mockReturnValue(new Map([["3956", { code: "3956", message: "Pending" }]])),
236+
success: true,
237+
}),
238+
30,
239+
),
240+
),
241+
);
229242

230243
const pollPromise = dialog.handleDecoupledTan("ref123", "Please confirm");
231244

232-
// Cancel after a short delay
245+
// Cancel before the first mock response resolves
233246
setTimeout(() => {
234247
dialog.cancelDecoupledTan();
235-
}, 100);
248+
}, 10);
236249

237250
await expect(pollPromise).rejects.toThrow(/cancelled/i);
238251
}, 10000);
239252

240253
test("should track status during polling", async () => {
241-
// Mock pending response
242-
mockConnection.send = jest.fn().mockResolvedValue({
243-
dialogId: "test-dialog",
244-
returnValues: jest.fn().mockReturnValue(new Map([["3956", { code: "3956", message: "Pending" }]])),
245-
success: true,
246-
});
254+
// Mock pending response with a delay so status check runs while request is in flight
255+
mockConnection.send = jest.fn().mockImplementation(
256+
() =>
257+
new Promise((resolve) =>
258+
setTimeout(
259+
() =>
260+
resolve({
261+
dialogId: "test-dialog",
262+
returnValues: jest
263+
.fn()
264+
.mockReturnValue(new Map([["3956", { code: "3956", message: "Pending" }]])),
265+
success: true,
266+
}),
267+
50,
268+
),
269+
),
270+
);
247271

248272
const pollPromise = dialog.handleDecoupledTan("ref123", "Please confirm");
249273

250-
// Check status while polling
251-
await new Promise((resolve) => setTimeout(resolve, 100));
274+
// Check status while the first mock response is still pending
275+
await new Promise((resolve) => setTimeout(resolve, 20));
252276
const status = dialog.checkDecoupledTanStatus();
253277

254278
expect(status).toBeDefined();

packages/fints/src/decoupled-tan/decoupled-tan-manager.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { HKTAN } from "../segments";
55
import { Response } from "../response";
66
import { TanMethod } from "../tan-method";
77
import { Request } from "../request";
8+
import { escapeFinTS } from "../utils";
89

910
/**
1011
* FinTS return code constants for decoupled TAN authentication
@@ -15,7 +16,8 @@ const RETURN_CODE_PENDING_CONFIRMATION = "3956"; // Strong customer authenticati
1516
/**
1617
* HKTAN process type for decoupled TAN status polling
1718
*/
18-
const HKTAN_PROCESS_DECOUPLED_STATUS = "2"; // Decoupled/asynchronous authentication status check
19+
// FinTS 3.0 Security PIN/TAN spec §TAN-Prozess=S: status polling for decoupled TAN
20+
const HKTAN_PROCESS_DECOUPLED_STATUS = "S";
1921

2022
/**
2123
* Default configuration for decoupled TAN
@@ -85,16 +87,17 @@ export class DecoupledTanManager {
8587
...config,
8688
};
8789

88-
// Override with server-provided values if available
90+
// Override with server-provided values if available.
91+
// HITANS timing fields are in seconds; convert to ms for internal use.
8992
if (tanMethod) {
9093
if (tanMethod.decoupledMaxStatusRequests !== undefined) {
9194
this.config.maxStatusRequests = tanMethod.decoupledMaxStatusRequests;
9295
}
9396
if (tanMethod.decoupledWaitBeforeFirstStatusRequest !== undefined) {
94-
this.config.waitBeforeFirstStatusRequest = tanMethod.decoupledWaitBeforeFirstStatusRequest;
97+
this.config.waitBeforeFirstStatusRequest = tanMethod.decoupledWaitBeforeFirstStatusRequest * 1000;
9598
}
9699
if (tanMethod.decoupledWaitBetweenStatusRequests !== undefined) {
97-
this.config.waitBetweenStatusRequests = tanMethod.decoupledWaitBetweenStatusRequests;
100+
this.config.waitBetweenStatusRequests = tanMethod.decoupledWaitBetweenStatusRequests * 1000;
98101
}
99102
}
100103

@@ -307,7 +310,7 @@ export class DecoupledTanManager {
307310
segNo: 3, // Standard position for HKTAN in status-only requests
308311
version,
309312
process: HKTAN_PROCESS_DECOUPLED_STATUS,
310-
aref: this.status.transactionReference,
313+
aref: escapeFinTS(this.status.transactionReference),
311314
}),
312315
];
313316

@@ -328,6 +331,10 @@ export class DecoupledTanManager {
328331
// Send the request using the dialog's connection
329332
const response = await this.dialog.connection.send(request);
330333

334+
// Keep dialog state in sync (dialog.send() is not used here to avoid TanRequiredError on 0030)
335+
this.dialog.dialogId = response.dialogId;
336+
this.dialog.msgNo++;
337+
331338
return response;
332339
}
333340

packages/fints/src/dialog.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
HKEND,
88
HISALS,
99
HIKAZS,
10+
HICAZS,
1011
HICDBS,
1112
HIUPD,
1213
HITANS,
@@ -113,6 +114,15 @@ export class Dialog extends DialogConfig {
113114
* Set to `true` during synchronization if the bank returns a HIKAZS parameter segment.
114115
*/
115116
public supportsTransactions = false;
117+
/**
118+
* Whether the bank supports fetching CAMT account transactions (HKCAZ).
119+
* Set to `true` during synchronization if the bank returns a HICAZS parameter segment.
120+
*/
121+
public supportsCamtTransactions = false;
122+
/** Version of the HKCAZ segment supported by the bank (from HICAZS). */
123+
public hicazsVersion = 1;
124+
/** CAMT format URI advertised by the bank in HICAZS, required in HKCAZ requests. */
125+
public hicazsCamtFormat = "";
116126
/**
117127
* Whether the bank supports fetching standing orders (HKCDB).
118128
* Set to `true` during synchronization if the bank returns a HICDBS parameter segment.
@@ -189,6 +199,11 @@ export class Dialog extends DialogConfig {
189199
const hikazsVer = response.segmentMaxVersion(HIKAZS);
190200
this.supportsTransactions = hikazsVer > 0;
191201
if (hikazsVer > 0) this.hikazsVersion = hikazsVer;
202+
const hicazsVer = response.segmentMaxVersion(HICAZS);
203+
this.supportsCamtTransactions = hicazsVer > 0;
204+
if (hicazsVer > 0) this.hicazsVersion = hicazsVer;
205+
const hicazs = response.findSegment(HICAZS);
206+
if (hicazs?.camtFormat) this.hicazsCamtFormat = hicazs.camtFormat;
192207
const hicdbVer = response.segmentMaxVersion(HICDBS);
193208
this.supportsStandingOrders = hicdbVer > 0;
194209
if (hicdbVer > 0) this.hicdbVersion = hicdbVer;

packages/fints/src/segments/hktan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export class HKTAN extends SegmentClass(HKTANProps) {
2222

2323
protected serialize() {
2424
const { process, segmentReference, aref, medium, version } = this;
25-
if (!["2", "4"].includes(process)) {
25+
if (!["2", "4", "S"].includes(process)) {
2626
throw new Error(`HKTAN process ${process} not implemented.`);
2727
}
2828
if (![3, 4, 5, 6, 7].includes(version)) {
@@ -49,7 +49,7 @@ export class HKTAN extends SegmentClass(HKTANProps) {
4949
return [process];
5050
}
5151
}
52-
} else if (process === "2") {
52+
} else if (process === "2" || process === "S") {
5353
if (version === 6 || version === 7) {
5454
return [process, "", "", "", aref, "N"];
5555
}

0 commit comments

Comments
 (0)