Skip to content

Commit b7736c3

Browse files
authored
Merge pull request #44 from larsdecker/copilot/update-fints-lib-handling
Handle decoupled TAN challenges when banks return 3955 without 0030
2 parents 8ff712a + 298a86d commit b7736c3

2 files changed

Lines changed: 43 additions & 9 deletions

File tree

packages/fints/src/__tests__/test-dialog.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Dialog, DialogConfig } from "../dialog";
22
import { Request } from "../request";
33
import { TanRequiredError } from "../errors/tan-required-error";
44
import { ResponseError } from "../errors/response-error";
5+
import { DecoupledTanState } from "../decoupled-tan";
56

67
describe("Dialog", () => {
78
const baseConfig: DialogConfig = { blz: "1", name: "user", pin: "123", systemId: "0" } as any;
@@ -53,6 +54,24 @@ describe("Dialog", () => {
5354
}
5455
});
5556

57+
test("send throws decoupled TanRequiredError for 3955 + HITAN without 0030", async () => {
58+
const hitan = { transactionReference: "ref", challengeText: "text", challengeMedia: Buffer.alloc(0) };
59+
const connection = {
60+
send: jest.fn().mockResolvedValue({
61+
success: true,
62+
returnValues: () => new Map([["3955", { message: "Sicherheitsfreigabe erfolgt über anderen Kanal." }]]),
63+
findSegment: () => hitan,
64+
dialogId: "4711",
65+
}),
66+
};
67+
const dialog = new Dialog(baseConfig, connection as any);
68+
const error = (await dialog.send(new Request(baseConfig)).catch((err) => err)) as TanRequiredError;
69+
expect(error).toBeInstanceOf(TanRequiredError);
70+
expect(error.decoupledTanState).toBe(DecoupledTanState.INITIATED);
71+
expect(error.context?.returnCode).toBe("3955");
72+
expect(dialog.dialogId).toBe("4711");
73+
});
74+
5675
test("capabilities getter reflects fields set during sync", () => {
5776
const dialog = new Dialog(baseConfig, {} as any);
5877

packages/fints/src/dialog.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -260,31 +260,46 @@ export class Dialog extends DialogConfig {
260260
if (!response.success) {
261261
throw new ResponseError(response);
262262
}
263-
if (response.returnValues().has("0030")) {
264-
const hitan = response.findSegment(HITAN);
265-
const returnValue = response.returnValues().get("0030");
263+
const returnValues = response.returnValues();
264+
const hasTanRequiredCode = returnValues.has("0030");
265+
let hitan: HITAN | undefined;
266+
let tanRequiredCode: "0030" | "3955" | undefined;
267+
if (hasTanRequiredCode) {
268+
tanRequiredCode = "0030";
269+
} else if (returnValues.has("3955")) {
270+
hitan = response.findSegment(HITAN);
271+
tanRequiredCode = hitan ? "3955" : undefined;
272+
}
273+
if (tanRequiredCode) {
274+
hitan = hitan ?? response.findSegment(HITAN);
275+
const returnValue = returnValues.get(tanRequiredCode);
266276

267277
// Determine which segment triggered the TAN requirement
268278
const triggeringSegment = request.segments.length > 0 ? request.segments[0].type : undefined;
269279

270280
// Check for decoupled TAN indicators per FinTS 3.0 PINTAN specification:
271281
// - "3956": Indicates strong customer authentication (SCA) is pending on trusted device
272282
// - "3076": PSD2-mandated strong customer authentication required
273-
// When either code is present alongside "0030", it signals decoupled TAN flow
274-
// where the user must approve the transaction in a separate app (e.g., mobile banking)
275-
const returnValues = response.returnValues();
276-
const isDecoupled = returnValues.has("3956") || returnValues.has("3076");
283+
// - "3955": Security approval takes place in another channel
284+
// These codes indicate a decoupled TAN flow where the user must approve the
285+
// transaction in a separate app or device. "3955" can either accompany "0030"
286+
// or be the primary TAN-required return code itself when returned with HITAN.
287+
const isDecoupled = returnValues.has("3956") || returnValues.has("3076") || returnValues.has("3955");
288+
const fallbackMessage =
289+
tanRequiredCode === "3955"
290+
? "TAN required: Security approval via alternate channel (3955)"
291+
: "TAN required";
277292

278293
const error = new TanRequiredError(
279-
returnValue.message,
294+
returnValue?.message ?? fallbackMessage,
280295
hitan.transactionReference,
281296
hitan.challengeText,
282297
hitan.challengeMedia,
283298
this,
284299
TanProcessStep.CHALLENGE_RESPONSE_NEEDED,
285300
triggeringSegment,
286301
{
287-
returnCode: "0030",
302+
returnCode: tanRequiredCode,
288303
requestSegments: request.segments.map((s) => s.type),
289304
},
290305
);

0 commit comments

Comments
 (0)