Skip to content

Commit 9857da2

Browse files
Copilotlarsdecker
andcommitted
Address code review feedback: fix race conditions, add constants, improve validation
- Add constants for FinTS return codes (RETURN_CODE_TAN_REQUIRED, RETURN_CODE_PENDING_CONFIRMATION) - Add constant for HKTAN process type (HKTAN_PROCESS_DECOUPLED_STATUS) - Fix timeout race condition by checking cancelled state before updating to TIMED_OUT - Add re-entry protection to prevent multiple simultaneous polling sessions - Fix status counter increment to occur after response processing, not before - Add validation for empty tanMethods array in Dialog.handleDecoupledTan - Remove unused autoStartPolling config option - Update documentation to clarify fixed-interval polling (not exponential backoff) - Add comment explaining hardcoded segNo=3 in checkStatus method Co-authored-by: larsdecker <1968186+larsdecker@users.noreply.github.com>
1 parent e408519 commit 9857da2

6 files changed

Lines changed: 57 additions & 27 deletions

File tree

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ describe("Decoupled TAN Integration", () => {
1717
});
1818

1919
const config: DecoupledTanConfig = {
20-
autoStartPolling: true,
2120
maxStatusRequests: 10,
2221
waitBeforeFirstStatusRequest: 10,
2322
waitBetweenStatusRequests: 10,

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ describe("DecoupledTanManager", () => {
4747

4848
test("should use custom config when provided", () => {
4949
const config: DecoupledTanConfig = {
50-
autoStartPolling: false,
5150
maxStatusRequests: 30,
5251
waitBeforeFirstStatusRequest: 1000,
5352
waitBetweenStatusRequests: 1500,

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

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,21 @@ import { Response } from "../response";
66
import { TanMethod } from "../tan-method";
77
import { Request } from "../request";
88

9+
/**
10+
* FinTS return code constants for decoupled TAN authentication
11+
*/
12+
const RETURN_CODE_TAN_REQUIRED = "0030"; // Order received - TAN/Security clearance required
13+
const RETURN_CODE_PENDING_CONFIRMATION = "3956"; // Strong customer authentication pending
14+
15+
/**
16+
* HKTAN process type for decoupled TAN status polling
17+
*/
18+
const HKTAN_PROCESS_DECOUPLED_STATUS = "2"; // Decoupled/asynchronous authentication status check
19+
920
/**
1021
* Default configuration for decoupled TAN
1122
*/
1223
const DEFAULT_CONFIG: Required<DecoupledTanConfig> = {
13-
autoStartPolling: true,
1424
maxStatusRequests: 60,
1525
waitBeforeFirstStatusRequest: 2000,
1626
waitBetweenStatusRequests: 2000,
@@ -35,7 +45,7 @@ export type DecoupledTanStatusCallback = (status: DecoupledTanStatus) => void |
3545
* - Process: tanProcess="2" (Decoupled/Asynchronous)
3646
*
3747
* **Key Features:**
38-
* - Automatic polling with exponential backoff
48+
* - Automatic polling with configurable fixed intervals
3949
* - State machine for TAN lifecycle (INITIATED → CHALLENGE_SENT → PENDING_CONFIRMATION → CONFIRMED)
4050
* - Configurable timeout handling
4151
* - Server-provided timing parameters from HITANS segment
@@ -56,6 +66,7 @@ export class DecoupledTanManager {
5666
private cancelled = false;
5767
private startTime: Date;
5868
private timeoutHandle?: ReturnType<typeof setTimeout>;
69+
private isPolling = false;
5970

6071
constructor(
6172
transactionReference: string,
@@ -131,6 +142,13 @@ export class DecoupledTanManager {
131142
* Start the polling process and wait for confirmation
132143
*/
133144
public async pollForConfirmation(statusCallback?: DecoupledTanStatusCallback): Promise<Response> {
145+
// Prevent multiple simultaneous polling attempts
146+
if (this.isPolling) {
147+
throw new Error("Polling is already in progress. Cannot start a new polling session.");
148+
}
149+
150+
this.isPolling = true;
151+
134152
// Set state to CHALLENGE_SENT
135153
this.updateState(DecoupledTanState.CHALLENGE_SENT);
136154
if (statusCallback) {
@@ -140,13 +158,16 @@ export class DecoupledTanManager {
140158
// Set up total timeout
141159
const timeoutPromise = new Promise<never>((_, reject) => {
142160
this.timeoutHandle = setTimeout(() => {
143-
this.updateState(DecoupledTanState.TIMED_OUT, "Total timeout exceeded");
144-
reject(
145-
new DecoupledTanError(
146-
`Decoupled TAN timed out after ${this.config.totalTimeout}ms`,
147-
this.getStatus(),
148-
),
149-
);
161+
// Check if already cancelled to avoid race condition
162+
if (!this.cancelled) {
163+
this.updateState(DecoupledTanState.TIMED_OUT, "Total timeout exceeded");
164+
reject(
165+
new DecoupledTanError(
166+
`Decoupled TAN timed out after ${this.config.totalTimeout}ms`,
167+
this.getStatus(),
168+
),
169+
);
170+
}
150171
}, this.config.totalTimeout);
151172
});
152173

@@ -181,6 +202,8 @@ export class DecoupledTanManager {
181202
this.timeoutHandle = undefined;
182203
}
183204
throw error;
205+
} finally {
206+
this.isPolling = false;
184207
}
185208
}
186209

@@ -204,9 +227,6 @@ export class DecoupledTanManager {
204227
try {
205228
const response = await this.checkStatus();
206229

207-
// Increment counter
208-
this.status.statusRequestCount++;
209-
210230
// Check response for confirmation or error codes
211231
const returnValues = response.returnValues();
212232

@@ -226,14 +246,17 @@ export class DecoupledTanManager {
226246

227247
// Check for confirmation: 0030 present WITHOUT 3956 means TAN was approved
228248
// If 3956 is still present, authentication is pending and we must continue polling
229-
if (returnValues.has("0030") && !returnValues.has("3956")) {
249+
if (returnValues.has(RETURN_CODE_TAN_REQUIRED) && !returnValues.has(RETURN_CODE_PENDING_CONFIRMATION)) {
250+
// TAN confirmed - increment counter and return
251+
this.status.statusRequestCount++;
230252
return response;
231253
}
232254

233255
// Check for still pending: 3956 means user hasn't approved yet
234256
// Continue polling until user confirms in their banking app
235-
if (returnValues.has("3956")) {
236-
// Still pending, continue polling
257+
if (returnValues.has(RETURN_CODE_PENDING_CONFIRMATION)) {
258+
// Still pending - increment counter and continue polling
259+
this.status.statusRequestCount++;
237260
if (statusCallback) {
238261
await statusCallback(this.getStatus());
239262
}
@@ -244,11 +267,13 @@ export class DecoupledTanManager {
244267
// Check for errors
245268
if (!response.success) {
246269
const errorMessages = response.errors.join(", ");
270+
this.status.statusRequestCount++;
247271
this.updateState(DecoupledTanState.FAILED, errorMessages);
248272
throw new DecoupledTanError(`Server error: ${errorMessages}`, this.getStatus());
249273
}
250274

251275
// Unexpected response, treat as confirmation
276+
this.status.statusRequestCount++;
252277
return response;
253278
} catch (error) {
254279
if (error instanceof DecoupledTanError) {
@@ -269,14 +294,19 @@ export class DecoupledTanManager {
269294

270295
/**
271296
* Make a single status check request
297+
*
298+
* Note: This uses segNo=3 as the HKTAN status request is typically the first business
299+
* segment after the dialog headers (HNHBK, HNVSK). While the exact segment number may
300+
* vary in different contexts, segNo=3 is the standard position for standalone HKTAN
301+
* status requests in the decoupled TAN flow.
272302
*/
273303
private async checkStatus(): Promise<Response> {
274304
const version = this.dialog.hktanVersion >= 7 ? 7 : this.dialog.hktanVersion;
275305
const segments = [
276306
new HKTAN({
277-
segNo: 3,
307+
segNo: 3, // Standard position for HKTAN in status-only requests
278308
version,
279-
process: "2",
309+
process: HKTAN_PROCESS_DECOUPLED_STATUS,
280310
aref: this.status.transactionReference,
281311
}),
282312
];

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,6 @@ export enum DecoupledTanState {
4141
* Configuration for decoupled TAN polling behavior
4242
*/
4343
export interface DecoupledTanConfig {
44-
/**
45-
* Automatically start polling when a decoupled TAN challenge is detected
46-
* @default true
47-
*/
48-
autoStartPolling?: boolean;
49-
5044
/**
5145
* Maximum number of status requests to make
5246
* Can be overridden by server-provided value from TAN method

packages/fints/src/dialog.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,14 @@ export class Dialog extends DialogConfig {
295295
this.tanMethods.find((m) => m.decoupledMaxStatusRequests !== undefined) || this.tanMethods[0];
296296
statusCallback?: DecoupledTanStatusCallback,
297297
): Promise<Response> {
298+
// Ensure TAN methods are available before attempting decoupled TAN handling
299+
if (!this.tanMethods || this.tanMethods.length === 0) {
300+
throw new Error(
301+
"No TAN methods are available for decoupled TAN handling. " +
302+
"Ensure the dialog is properly initialized and TAN methods have been retrieved.",
303+
);
304+
}
305+
298306
// Find the appropriate TAN method (prefer one with decoupled support)
299307
const tanMethod = this.tanMethods.find((m) => m.decoupledMaxStatusRequests !== undefined) || this.tanMethods[0];
300308

packages/fints/src/pin-tan-client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ export class PinTanClient extends Client {
125125
if (!error.isDecoupledTan()) {
126126
throw new Error(
127127
"The provided TanRequiredError does not represent a decoupled TAN challenge. " +
128-
"Only call handleDecoupledTanChallenge when error.isDecoupledTan() returns true. " +
129-
"For regular TAN challenges, use your normal TAN handling flow instead."
128+
"Only call handleDecoupledTanChallenge when error.isDecoupledTan() returns true. " +
129+
"For regular TAN challenges, use your normal TAN handling flow instead.",
130130
);
131131
}
132132

0 commit comments

Comments
 (0)