Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/fints/src/__tests__/test-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Dialog, DialogConfig } from "../dialog";
import { Request } from "../request";
import { TanRequiredError } from "../errors/tan-required-error";
import { ResponseError } from "../errors/response-error";
import { DecoupledTanState } from "../decoupled-tan";

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

test("send throws decoupled TanRequiredError for 3955 + HITAN without 0030", async () => {
const hitan = { transactionReference: "ref", challengeText: "text", challengeMedia: Buffer.alloc(0) };
const connection = {
send: jest.fn().mockResolvedValue({
success: true,
returnValues: () => new Map([["3955", { message: "Sicherheitsfreigabe erfolgt über anderen Kanal." }]]),
findSegment: () => hitan,
dialogId: "4711",
}),
};
const dialog = new Dialog(baseConfig, connection as any);
const error = (await dialog.send(new Request(baseConfig)).catch((err) => err)) as TanRequiredError;
expect(error).toBeInstanceOf(TanRequiredError);
expect(error.decoupledTanState).toBe(DecoupledTanState.INITIATED);
expect(error.context?.returnCode).toBe("3955");
expect(dialog.dialogId).toBe("4711");
});

test("capabilities getter reflects fields set during sync", () => {
const dialog = new Dialog(baseConfig, {} as any);

Expand Down
33 changes: 24 additions & 9 deletions packages/fints/src/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,31 +260,46 @@ export class Dialog extends DialogConfig {
if (!response.success) {
throw new ResponseError(response);
}
if (response.returnValues().has("0030")) {
const hitan = response.findSegment(HITAN);
const returnValue = response.returnValues().get("0030");
const returnValues = response.returnValues();
const hasTanRequiredCode = returnValues.has("0030");
let hitan: HITAN | undefined;
let tanRequiredCode: "0030" | "3955" | undefined;
if (hasTanRequiredCode) {
tanRequiredCode = "0030";
} else if (returnValues.has("3955")) {
hitan = response.findSegment(HITAN);
tanRequiredCode = hitan ? "3955" : undefined;
}
if (tanRequiredCode) {
hitan = hitan ?? response.findSegment(HITAN);
const returnValue = returnValues.get(tanRequiredCode);

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

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

const error = new TanRequiredError(
returnValue.message,
returnValue?.message ?? fallbackMessage,
hitan.transactionReference,
hitan.challengeText,
hitan.challengeMedia,
this,
TanProcessStep.CHALLENGE_RESPONSE_NEEDED,
triggeringSegment,
{
returnCode: "0030",
returnCode: tanRequiredCode,
requestSegments: request.segments.map((s) => s.type),
},
);
Expand Down
Loading