@coderoftime/fints 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +28 -0
- package/README.md +156 -2
- package/dist/core/account.d.ts +140 -0
- package/dist/core/account.d.ts.map +1 -0
- package/dist/core/account.js +174 -0
- package/dist/core/account.js.map +1 -0
- package/dist/core/amount.d.ts +62 -0
- package/dist/core/amount.d.ts.map +1 -0
- package/dist/core/amount.js +189 -0
- package/dist/core/amount.js.map +1 -0
- package/dist/core/balance.d.ts +54 -0
- package/dist/core/balance.d.ts.map +1 -0
- package/dist/core/balance.js +53 -0
- package/dist/core/balance.js.map +1 -0
- package/dist/core/bank-info.d.ts +49 -0
- package/dist/core/bank-info.d.ts.map +1 -0
- package/dist/core/bank-info.js +52 -0
- package/dist/core/bank-info.js.map +1 -0
- package/dist/core/client.d.ts +126 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +2 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/config.d.ts +68 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +113 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/errors.d.ts +91 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +182 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/feedback.d.ts +69 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +124 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/transaction.d.ts +98 -0
- package/dist/core/transaction.d.ts.map +1 -0
- package/dist/core/transaction.js +91 -0
- package/dist/core/transaction.js.map +1 -0
- package/dist/core/transport.d.ts +37 -0
- package/dist/core/transport.d.ts.map +1 -0
- package/dist/core/transport.js +92 -0
- package/dist/core/transport.js.map +1 -0
- package/dist/formats/camt.d.ts +12 -0
- package/dist/formats/camt.d.ts.map +1 -0
- package/dist/formats/camt.js +159 -0
- package/dist/formats/camt.js.map +1 -0
- package/dist/formats/mt940.d.ts +42 -0
- package/dist/formats/mt940.d.ts.map +1 -0
- package/dist/formats/mt940.js +297 -0
- package/dist/formats/mt940.js.map +1 -0
- package/dist/formats/readers.d.ts +11 -0
- package/dist/formats/readers.d.ts.map +1 -0
- package/dist/formats/readers.js +47 -0
- package/dist/formats/readers.js.map +1 -0
- package/dist/formats/sepa-purpose.d.ts +22 -0
- package/dist/formats/sepa-purpose.d.ts.map +1 -0
- package/dist/formats/sepa-purpose.js +68 -0
- package/dist/formats/sepa-purpose.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/session/app-approval.d.ts +60 -0
- package/dist/session/app-approval.d.ts.map +1 -0
- package/dist/session/app-approval.js +215 -0
- package/dist/session/app-approval.js.map +1 -0
- package/dist/session/bank-connection.d.ts +28 -0
- package/dist/session/bank-connection.d.ts.map +1 -0
- package/dist/session/bank-connection.js +48 -0
- package/dist/session/bank-connection.js.map +1 -0
- package/dist/session/dialog-runner.d.ts +34 -0
- package/dist/session/dialog-runner.d.ts.map +1 -0
- package/dist/session/dialog-runner.js +112 -0
- package/dist/session/dialog-runner.js.map +1 -0
- package/dist/session/login-guard.d.ts +73 -0
- package/dist/session/login-guard.d.ts.map +1 -0
- package/dist/session/login-guard.js +109 -0
- package/dist/session/login-guard.js.map +1 -0
- package/dist/session/parameter-data.d.ts +83 -0
- package/dist/session/parameter-data.d.ts.map +1 -0
- package/dist/session/parameter-data.js +68 -0
- package/dist/session/parameter-data.js.map +1 -0
- package/dist/session/pin-tan-client.d.ts +54 -0
- package/dist/session/pin-tan-client.d.ts.map +1 -0
- package/dist/session/pin-tan-client.js +153 -0
- package/dist/session/pin-tan-client.js.map +1 -0
- package/dist/session/pin-tan-dialog.d.ts +71 -0
- package/dist/session/pin-tan-dialog.d.ts.map +1 -0
- package/dist/session/pin-tan-dialog.js +124 -0
- package/dist/session/pin-tan-dialog.js.map +1 -0
- package/dist/session/protocol.d.ts +209 -0
- package/dist/session/protocol.d.ts.map +1 -0
- package/dist/session/protocol.js +75 -0
- package/dist/session/protocol.js.map +1 -0
- package/dist/session/response-handler.d.ts +48 -0
- package/dist/session/response-handler.d.ts.map +1 -0
- package/dist/session/response-handler.js +121 -0
- package/dist/session/response-handler.js.map +1 -0
- package/dist/session/sepa-accounts.d.ts +34 -0
- package/dist/session/sepa-accounts.d.ts.map +1 -0
- package/dist/session/sepa-accounts.js +96 -0
- package/dist/session/sepa-accounts.js.map +1 -0
- package/dist/session/session-setup.d.ts +72 -0
- package/dist/session/session-setup.d.ts.map +1 -0
- package/dist/session/session-setup.js +160 -0
- package/dist/session/session-setup.js.map +1 -0
- package/dist/session/session-state.d.ts +47 -0
- package/dist/session/session-state.d.ts.map +1 -0
- package/dist/session/session-state.js +147 -0
- package/dist/session/session-state.js.map +1 -0
- package/dist/session/tan-selection.d.ts +88 -0
- package/dist/session/tan-selection.d.ts.map +1 -0
- package/dist/session/tan-selection.js +178 -0
- package/dist/session/tan-selection.js.map +1 -0
- package/dist/session/user-prompts.d.ts +61 -0
- package/dist/session/user-prompts.d.ts.map +1 -0
- package/dist/session/user-prompts.js +164 -0
- package/dist/session/user-prompts.js.map +1 -0
- package/dist/tan/challenge.d.ts +114 -0
- package/dist/tan/challenge.d.ts.map +1 -0
- package/dist/tan/challenge.js +185 -0
- package/dist/tan/challenge.js.map +1 -0
- package/dist/tan/flicker.d.ts +36 -0
- package/dist/tan/flicker.d.ts.map +1 -0
- package/dist/tan/flicker.js +262 -0
- package/dist/tan/flicker.js.map +1 -0
- package/dist/tan/tan-image.d.ts +21 -0
- package/dist/tan/tan-image.d.ts.map +1 -0
- package/dist/tan/tan-image.js +41 -0
- package/dist/tan/tan-image.js.map +1 -0
- package/dist/tan/tan-models.d.ts +145 -0
- package/dist/tan/tan-models.d.ts.map +1 -0
- package/dist/tan/tan-models.js +148 -0
- package/dist/tan/tan-models.js.map +1 -0
- package/dist/util/async.d.ts +24 -0
- package/dist/util/async.d.ts.map +1 -0
- package/dist/util/async.js +49 -0
- package/dist/util/async.js.map +1 -0
- package/dist/util/bytes.d.ts +23 -0
- package/dist/util/bytes.d.ts.map +1 -0
- package/dist/util/bytes.js +136 -0
- package/dist/util/bytes.js.map +1 -0
- package/dist/util/call-context.d.ts +12 -0
- package/dist/util/call-context.d.ts.map +1 -0
- package/dist/util/call-context.js +29 -0
- package/dist/util/call-context.js.map +1 -0
- package/dist/util/dates.d.ts +62 -0
- package/dist/util/dates.d.ts.map +1 -0
- package/dist/util/dates.js +169 -0
- package/dist/util/dates.js.map +1 -0
- package/dist/util/errors.d.ts +9 -0
- package/dist/util/errors.d.ts.map +1 -0
- package/dist/util/errors.js +15 -0
- package/dist/util/errors.js.map +1 -0
- package/dist/util/immutable-map.d.ts +16 -0
- package/dist/util/immutable-map.d.ts.map +1 -0
- package/dist/util/immutable-map.js +38 -0
- package/dist/util/immutable-map.js.map +1 -0
- package/dist/util/json.d.ts +13 -0
- package/dist/util/json.d.ts.map +1 -0
- package/dist/util/json.js +44 -0
- package/dist/util/json.js.map +1 -0
- package/dist/util/text.d.ts +20 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +78 -0
- package/dist/util/text.js.map +1 -0
- package/dist/util/xml.d.ts +35 -0
- package/dist/util/xml.d.ts.map +1 -0
- package/dist/util/xml.js +183 -0
- package/dist/util/xml.js.map +1 -0
- package/dist/v3/account-extension.d.ts +25 -0
- package/dist/v3/account-extension.d.ts.map +1 -0
- package/dist/v3/account-extension.js +176 -0
- package/dist/v3/account-extension.js.map +1 -0
- package/dist/v3/client.d.ts +24 -0
- package/dist/v3/client.d.ts.map +1 -0
- package/dist/v3/client.js +16 -0
- package/dist/v3/client.js.map +1 -0
- package/dist/v3/dialog.d.ts +78 -0
- package/dist/v3/dialog.d.ts.map +1 -0
- package/dist/v3/dialog.js +138 -0
- package/dist/v3/dialog.js.map +1 -0
- package/dist/v3/message.d.ts +72 -0
- package/dist/v3/message.d.ts.map +1 -0
- package/dist/v3/message.js +259 -0
- package/dist/v3/message.js.map +1 -0
- package/dist/v3/parameters.d.ts +79 -0
- package/dist/v3/parameters.d.ts.map +1 -0
- package/dist/v3/parameters.js +396 -0
- package/dist/v3/parameters.js.map +1 -0
- package/dist/v3/protocol.d.ts +68 -0
- package/dist/v3/protocol.d.ts.map +1 -0
- package/dist/v3/protocol.js +263 -0
- package/dist/v3/protocol.js.map +1 -0
- package/dist/v3/segments.d.ts +89 -0
- package/dist/v3/segments.d.ts.map +1 -0
- package/dist/v3/segments.js +283 -0
- package/dist/v3/segments.js.map +1 -0
- package/dist/v3/syntax.d.ts +76 -0
- package/dist/v3/syntax.d.ts.map +1 -0
- package/dist/v3/syntax.js +301 -0
- package/dist/v3/syntax.js.map +1 -0
- package/package.json +63 -4
- package/src/core/account.ts +250 -0
- package/src/core/amount.ts +209 -0
- package/src/core/balance.ts +84 -0
- package/src/core/bank-info.ts +66 -0
- package/src/core/client.ts +149 -0
- package/src/core/config.ts +141 -0
- package/src/core/errors.ts +203 -0
- package/src/core/feedback.ts +137 -0
- package/src/core/transaction.ts +156 -0
- package/src/core/transport.ts +114 -0
- package/src/formats/camt.ts +169 -0
- package/src/formats/mt940.ts +333 -0
- package/src/formats/readers.ts +56 -0
- package/src/formats/sepa-purpose.ts +75 -0
- package/src/index.ts +68 -0
- package/src/session/app-approval.ts +265 -0
- package/src/session/bank-connection.ts +51 -0
- package/src/session/dialog-runner.ts +119 -0
- package/src/session/login-guard.ts +135 -0
- package/src/session/parameter-data.ts +137 -0
- package/src/session/pin-tan-client.ts +186 -0
- package/src/session/pin-tan-dialog.ts +160 -0
- package/src/session/protocol.ts +311 -0
- package/src/session/response-handler.ts +130 -0
- package/src/session/sepa-accounts.ts +97 -0
- package/src/session/session-setup.ts +165 -0
- package/src/session/session-state.ts +170 -0
- package/src/session/tan-selection.ts +192 -0
- package/src/session/user-prompts.ts +174 -0
- package/src/tan/challenge.ts +252 -0
- package/src/tan/flicker.ts +270 -0
- package/src/tan/tan-image.ts +36 -0
- package/src/tan/tan-models.ts +247 -0
- package/src/util/async.ts +53 -0
- package/src/util/bytes.ts +135 -0
- package/src/util/call-context.ts +40 -0
- package/src/util/dates.ts +193 -0
- package/src/util/errors.ts +16 -0
- package/src/util/immutable-map.ts +47 -0
- package/src/util/json.ts +51 -0
- package/src/util/text.ts +82 -0
- package/src/util/xml.ts +172 -0
- package/src/v3/account-extension.ts +190 -0
- package/src/v3/client.ts +29 -0
- package/src/v3/dialog.ts +199 -0
- package/src/v3/message.ts +321 -0
- package/src/v3/parameters.ts +451 -0
- package/src/v3/protocol.ts +331 -0
- package/src/v3/segments.ts +331 -0
- package/src/v3/syntax.ts +307 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { FinTsDecoupledTimeoutError, FinTsTanCancelledError } from '../core/errors.js';
|
|
2
|
+
import { FeedbackCodes, hasCode } from '../core/feedback.js';
|
|
3
|
+
import { nextAttempt, withResolved, type TanAnswer, type TanChallenge } from '../tan/challenge.js';
|
|
4
|
+
import type { TanMethod } from '../tan/tan-models.js';
|
|
5
|
+
import { Deferred } from '../util/async.js';
|
|
6
|
+
import { invalidArgument } from '../util/errors.js';
|
|
7
|
+
import type { Approval, BankResponse } from './protocol.js';
|
|
8
|
+
|
|
9
|
+
/** How long to wait for an app approval when the bank does not say. */
|
|
10
|
+
const defaultMaxStatusPolls = 60;
|
|
11
|
+
const defaultFirstPollDelayMs = 2_000;
|
|
12
|
+
const defaultPollIntervalMs = 2_000;
|
|
13
|
+
|
|
14
|
+
/** Bounds for the bank's polling parameters, against faulty parameter data. */
|
|
15
|
+
const minPollIntervalMs = 1_000;
|
|
16
|
+
const maxPollDelayMs = 60_000;
|
|
17
|
+
const maxApprovalWaitMs = 10 * 60_000;
|
|
18
|
+
|
|
19
|
+
/** Shows a challenge and returns the user's answer. */
|
|
20
|
+
export type AskUser = (challenge: TanChallenge) => Promise<TanAnswer>;
|
|
21
|
+
|
|
22
|
+
/** Waits for `delayMs`, or until `interrupt` settles. */
|
|
23
|
+
export type Pause = (delayMs: number, interrupt?: Promise<unknown>) => Promise<void>;
|
|
24
|
+
|
|
25
|
+
/** What an `AppApprovalWait` needs: `ask` shows a challenge, `pause` waits, `receive` takes in each status response. */
|
|
26
|
+
export interface AppApprovalHooks<R> {
|
|
27
|
+
readonly ask: AskUser;
|
|
28
|
+
readonly pause: Pause;
|
|
29
|
+
/** Takes in each status response; throws if it reports errors. */
|
|
30
|
+
readonly receive: (response: R) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Waits for the user to approve a message in the banking app, and for the bank to confirm it (PIN/TAN B.4.2.2).
|
|
35
|
+
*
|
|
36
|
+
* The bank's parameters decide who triggers the status requests:
|
|
37
|
+
* - The client polls by itself while the callback shows the instructions (`PollingWhileShown`) where the bank allows
|
|
38
|
+
* automatic status requests and announces no push message (3957), and also where the user cannot report the
|
|
39
|
+
* approval: then polling is the one way left to complete it, whatever the other parameters say (step 2a).
|
|
40
|
+
* - Otherwise the client asks after each report of the user (`PollingAfterReports`), which stands in for the push
|
|
41
|
+
* message the client cannot receive.
|
|
42
|
+
*
|
|
43
|
+
* The bank's limit on status requests holds either way, and two requests are at least a second apart. One instance
|
|
44
|
+
* handles one `Approval`; `run` may be called once. A failure of `ask` or `pause`, such as a closed client, ends the
|
|
45
|
+
* wait.
|
|
46
|
+
*/
|
|
47
|
+
export function appApprovalWait<R extends BankResponse>(
|
|
48
|
+
approval: Approval<R>,
|
|
49
|
+
hooks: AppApprovalHooks<R>,
|
|
50
|
+
): AppApprovalWait<R> {
|
|
51
|
+
const method = approval.challenge.method;
|
|
52
|
+
const automatic = !method.decoupledManualConfirmation || (method.decoupledAutomaticPolling && !approval.waitForPush);
|
|
53
|
+
return automatic ? new PollingWhileShown(approval, hooks) : new PollingAfterReports(approval, hooks);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export abstract class AppApprovalWait<R extends BankResponse> {
|
|
57
|
+
private requests = 0;
|
|
58
|
+
|
|
59
|
+
/** The time the client waited by itself, which counts towards the longest wait. */
|
|
60
|
+
protected waitedMs = 0;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
protected readonly approval: Approval<R>,
|
|
64
|
+
protected readonly hooks: AppApprovalHooks<R>,
|
|
65
|
+
) {}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns the bank's response once the approval is through. Throws `FinTsTanCancelledError` if the user cancels and
|
|
69
|
+
* `FinTsDecoupledTimeoutError` if the approval takes too long.
|
|
70
|
+
*/
|
|
71
|
+
abstract run(): Promise<R>;
|
|
72
|
+
|
|
73
|
+
protected get method(): TanMethod {
|
|
74
|
+
return this.approval.challenge.method;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether the user may report the approval (PIN/TAN, "Manuelle Bestätigung möglich"). */
|
|
78
|
+
protected get userConfirms(): boolean {
|
|
79
|
+
return this.method.decoupledManualConfirmation;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected get pollIntervalMs(): number {
|
|
83
|
+
return clamp(
|
|
84
|
+
seconds(this.method.decoupledPollIntervalSeconds) ?? defaultPollIntervalMs,
|
|
85
|
+
minPollIntervalMs,
|
|
86
|
+
maxPollDelayMs,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
protected get firstPollDelayMs(): number {
|
|
91
|
+
return clamp(seconds(this.method.decoupledFirstPollDelaySeconds) ?? defaultFirstPollDelayMs, 0, maxPollDelayMs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Sends the next status request and returns the answer, once `receive` has taken it in.
|
|
96
|
+
*
|
|
97
|
+
* Throws if the answer reports errors, and `FinTsDecoupledTimeoutError` if the approval is still pending after as
|
|
98
|
+
* many requests as the bank allows, or after the longest wait.
|
|
99
|
+
*/
|
|
100
|
+
protected async requestStatus(): Promise<R> {
|
|
101
|
+
const response = await this.approval.checkStatus();
|
|
102
|
+
this.requests++;
|
|
103
|
+
this.hooks.receive(response);
|
|
104
|
+
// A maximum of 0 allows any number of requests (PIN/TAN, "Maximale Anzahl Statusabfragen Decoupled").
|
|
105
|
+
const maxRequests = this.method.decoupledMaxPolls ?? defaultMaxStatusPolls;
|
|
106
|
+
if (
|
|
107
|
+
isPending(response) &&
|
|
108
|
+
((maxRequests > 0 && this.requests >= maxRequests) || this.waitedMs >= maxApprovalWaitMs)
|
|
109
|
+
) {
|
|
110
|
+
throw new FinTsDecoupledTimeoutError();
|
|
111
|
+
}
|
|
112
|
+
return response;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `challenge` as the next prompt, with the new instructions of `pending`, the last status answer, where it gives any
|
|
117
|
+
* (PIN/TAN B.4.2.2.1, step 2b).
|
|
118
|
+
*/
|
|
119
|
+
protected nextPrompt(challenge: TanChallenge, pending: R): TanChallenge {
|
|
120
|
+
return nextAttempt(challenge, this.approval.instructionsIn(pending));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The client polls by itself while the callback shows the instructions, and settles `TanChallenge.resolved` when it is
|
|
126
|
+
* done.
|
|
127
|
+
*
|
|
128
|
+
* `run` shows the prompt, waits the bank's first delay, then asks the bank until it confirms, waiting the interval in
|
|
129
|
+
* between. The callback's answer only records what the user did:
|
|
130
|
+
* - `stopped` settles on a cancel, a TAN or a failing callback; the loop stops before its next request.
|
|
131
|
+
* - `report` settles on a report of the approval, which cuts the current wait short once, though not below a second.
|
|
132
|
+
* The report closed the prompt, so it is shown anew while the bank has not confirmed yet.
|
|
133
|
+
* - A user who cannot report the approval has every answer but `TanAnswer.cancelled` ignored.
|
|
134
|
+
*/
|
|
135
|
+
class PollingWhileShown<R extends BankResponse> extends AppApprovalWait<R> {
|
|
136
|
+
/** Settles when the client needs no more answer, which `TanChallenge.resolved` tells the callback. */
|
|
137
|
+
private readonly finished = new Deferred<void>();
|
|
138
|
+
|
|
139
|
+
/** Settles when the user cancels or the callback fails; `stopReason` holds the error that ends the wait. */
|
|
140
|
+
private readonly stopped = new Deferred<void>();
|
|
141
|
+
private stopReason: { error: unknown } | undefined;
|
|
142
|
+
|
|
143
|
+
/** Settles when the user reports the approval in the prompt shown now; `undefined` once the report was used. */
|
|
144
|
+
private report: Deferred<void> | undefined;
|
|
145
|
+
|
|
146
|
+
async run(): Promise<R> {
|
|
147
|
+
let challenge = this.approval.challenge;
|
|
148
|
+
this.show(challenge);
|
|
149
|
+
try {
|
|
150
|
+
await this.sleep(this.firstPollDelayMs);
|
|
151
|
+
for (;;) {
|
|
152
|
+
this.throwIfStopped();
|
|
153
|
+
// A request under way is awaited even when the user cancels meanwhile; its answer counts.
|
|
154
|
+
const response = await this.requestStatus();
|
|
155
|
+
if (!isPending(response)) return response;
|
|
156
|
+
this.throwIfStopped();
|
|
157
|
+
// The user reported the approval, which closed the prompt, but the bank has not confirmed it yet.
|
|
158
|
+
if (this.userConfirms && this.report === undefined)
|
|
159
|
+
this.show((challenge = this.nextPrompt(challenge, response)));
|
|
160
|
+
await this.sleep(this.pollIntervalMs, minPollIntervalMs);
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
this.finished.resolve();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Shows `challenge` without waiting for the answer. */
|
|
168
|
+
private show(challenge: TanChallenge): void {
|
|
169
|
+
const report = (this.report = this.userConfirms ? new Deferred<void>() : undefined);
|
|
170
|
+
void this.hooks.ask(withResolved(challenge, this.finished.promise)).then(
|
|
171
|
+
(answer) => {
|
|
172
|
+
switch (answer.kind) {
|
|
173
|
+
case 'cancelled':
|
|
174
|
+
this.stop(new FinTsTanCancelledError());
|
|
175
|
+
break;
|
|
176
|
+
case 'approved':
|
|
177
|
+
report?.resolve();
|
|
178
|
+
break;
|
|
179
|
+
case 'tan':
|
|
180
|
+
if (report !== undefined) this.stop(tanForApproval());
|
|
181
|
+
break;
|
|
182
|
+
// The user cannot report the approval: the client waits for the bank.
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
(error: unknown) => {
|
|
186
|
+
this.stop(error);
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private stop(error: unknown): void {
|
|
192
|
+
if (this.stopped.isCompleted) return;
|
|
193
|
+
this.stopReason = { error };
|
|
194
|
+
this.stopped.resolve();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private throwIfStopped(): void {
|
|
198
|
+
if (this.stopReason !== undefined) throw this.stopReason.error;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Waits for `delayMs`, or until the user cancels or reports the approval; a report does not cut the wait below
|
|
203
|
+
* `floorMs`. A wait cut short counts towards the longest wait with its floor only.
|
|
204
|
+
*/
|
|
205
|
+
private async sleep(delayMs: number, floorMs = 0): Promise<void> {
|
|
206
|
+
const report = this.report;
|
|
207
|
+
if (floorMs > 0) await this.hooks.pause(floorMs, this.stopped.promise);
|
|
208
|
+
const interrupt =
|
|
209
|
+
report === undefined ? this.stopped.promise : Promise.race([this.stopped.promise, report.promise]);
|
|
210
|
+
await this.hooks.pause(delayMs - floorMs, interrupt);
|
|
211
|
+
const cutShort = report?.isCompleted ?? false;
|
|
212
|
+
this.waitedMs += cutShort ? floorMs : delayMs;
|
|
213
|
+
// One report, one early request.
|
|
214
|
+
if (cutShort) this.report = undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The client asks the bank after each report of the user; while the approval is pending, the user is asked again with
|
|
220
|
+
* the bank's latest instructions.
|
|
221
|
+
*/
|
|
222
|
+
class PollingAfterReports<R extends BankResponse> extends AppApprovalWait<R> {
|
|
223
|
+
async run(): Promise<R> {
|
|
224
|
+
let challenge = this.approval.challenge;
|
|
225
|
+
await this.confirm(challenge);
|
|
226
|
+
for (;;) {
|
|
227
|
+
const response = await this.requestStatus();
|
|
228
|
+
if (!isPending(response)) return response;
|
|
229
|
+
challenge = this.nextPrompt(challenge, response);
|
|
230
|
+
// The prompt comes back at once; however fast the user reports again, the next request waits a second.
|
|
231
|
+
await Promise.all([this.confirm(challenge), this.hooks.pause(minPollIntervalMs)]);
|
|
232
|
+
this.waitedMs += minPollIntervalMs;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Shows `challenge` and waits until the user reports the approval or cancels. */
|
|
237
|
+
private async confirm(challenge: TanChallenge): Promise<void> {
|
|
238
|
+
const answer = await this.hooks.ask(challenge);
|
|
239
|
+
switch (answer.kind) {
|
|
240
|
+
case 'approved':
|
|
241
|
+
return;
|
|
242
|
+
case 'cancelled':
|
|
243
|
+
throw new FinTsTanCancelledError();
|
|
244
|
+
case 'tan':
|
|
245
|
+
throw tanForApproval();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether `response` tells that the approval is still pending (return code 3956). */
|
|
251
|
+
function isPending(response: BankResponse): boolean {
|
|
252
|
+
return hasCode(response.feedback, FeedbackCodes.decoupledStillPending);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function tanForApproval(): TypeError {
|
|
256
|
+
return invalidArgument('Answered an approval in the banking app with a TAN', 'tanCallback');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function seconds(value: number | undefined): number | undefined {
|
|
260
|
+
return value === undefined ? undefined : value * 1000;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function clamp(value: number, min: number, max: number): number {
|
|
264
|
+
return value < min ? min : value > max ? max : value;
|
|
265
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { MessageDirection, MessageLogger } from '../core/client.js';
|
|
2
|
+
import { FinTsError, FinTsProtocolError, FinTsTransportError } from '../core/errors.js';
|
|
3
|
+
import { HttpTransport, type FinTsTransport } from '../core/transport.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Throws `FinTsProtocolError` if a message of `size` bytes exceeds the bank's `limit` in kilobytes (1024 bytes): the
|
|
7
|
+
* specification does not allow to send such a message.
|
|
8
|
+
*/
|
|
9
|
+
export function checkMessageSize(size: number, limit: number | undefined): void {
|
|
10
|
+
if (limit !== undefined && limit > 0 && size > limit * 1024) {
|
|
11
|
+
throw new FinTsProtocolError(`A message of ${size} bytes exceeds the bank's maximum message size of ${limit} KB`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The bank's FinTS endpoint: posts messages there and reports them to the logger. */
|
|
16
|
+
export class BankConnection {
|
|
17
|
+
private readonly transport: FinTsTransport;
|
|
18
|
+
|
|
19
|
+
/** A connection to `url` through `transport`, an `HttpTransport` by default. */
|
|
20
|
+
constructor(
|
|
21
|
+
/** Where the messages go. */
|
|
22
|
+
readonly url: URL,
|
|
23
|
+
transport: FinTsTransport | undefined,
|
|
24
|
+
private readonly logger: MessageLogger | undefined,
|
|
25
|
+
) {
|
|
26
|
+
this.transport = transport ?? new HttpTransport();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Posts `body`; reports any failure of the transport as `FinTsError`, with the original as its `cause`: a custom
|
|
31
|
+
* transport that calls `fetch` fails with a `TypeError` where the network does.
|
|
32
|
+
*/
|
|
33
|
+
async post(body: Uint8Array, contentType: string): Promise<Uint8Array> {
|
|
34
|
+
try {
|
|
35
|
+
return await this.transport.post(this.url, body, contentType);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof FinTsError) throw error;
|
|
38
|
+
throw new FinTsTransportError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Reports a message to the logger; `message` is only built when a logger is set. */
|
|
43
|
+
log(direction: MessageDirection, message: () => string): void {
|
|
44
|
+
this.logger?.(direction, message());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Closes the transport. */
|
|
48
|
+
close(): void {
|
|
49
|
+
this.transport.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { FinTsBankError, FinTsProtocolError, parseBankData } from '../core/errors.js';
|
|
2
|
+
import { continuations, FeedbackCodes, hasCode } from '../core/feedback.js';
|
|
3
|
+
import type { BankResponse, ProtocolDialog, Query } from './protocol.js';
|
|
4
|
+
import type { ResponseHandler } from './response-handler.js';
|
|
5
|
+
import type { SessionSetup } from './session-setup.js';
|
|
6
|
+
import type { UserPrompts } from './user-prompts.js';
|
|
7
|
+
|
|
8
|
+
/** Most pages the client fetches for one query, far more than banks send. */
|
|
9
|
+
const maxPages = 1000;
|
|
10
|
+
|
|
11
|
+
/** Most data one query may hold, summed over its pages: the transport bounds a single response only. */
|
|
12
|
+
const maxQueryMebibytes = 128;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Keeps one dialog open for all calls and runs the queries in it.
|
|
16
|
+
*
|
|
17
|
+
* A dialog the bank has ended is not used again: the call that learns of it fails, and the next call logs in anew. The
|
|
18
|
+
* client does not repeat the call by itself, since the bank's answer cannot tell a dialog that had ended before from an
|
|
19
|
+
* order it refuses along with ending the dialog, and a new login may cost the user another approval.
|
|
20
|
+
*/
|
|
21
|
+
export class DialogRunner<R extends BankResponse, O> {
|
|
22
|
+
private dialog: ProtocolDialog<R, O> | undefined;
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly setup: SessionSetup<R, O>,
|
|
26
|
+
private readonly responses: ResponseHandler<R>,
|
|
27
|
+
private readonly prompts: UserPrompts,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** The open dialog, or a new one once the setup is complete and the user has passed strong authentication. */
|
|
31
|
+
async open(): Promise<ProtocolDialog<R, O>> {
|
|
32
|
+
const current = this.dialog;
|
|
33
|
+
if (current?.isActive === true) return current;
|
|
34
|
+
this.dialog = undefined;
|
|
35
|
+
const dialog = await this.setup.remedied(() => this.login());
|
|
36
|
+
this.dialog = dialog;
|
|
37
|
+
return dialog;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async login(): Promise<ProtocolDialog<R, O>> {
|
|
41
|
+
const dialog = await this.setup.newDialog();
|
|
42
|
+
try {
|
|
43
|
+
await this.responses.startDialog(dialog, () => dialog.start());
|
|
44
|
+
return dialog;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// After a refused login the dialog is discarded already, and ending it sends nothing. After any other failure,
|
|
47
|
+
// such as a cancelled TAN, the bank still knows the dialog.
|
|
48
|
+
await dialog.end();
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Ends the open dialog, if there is one. */
|
|
54
|
+
async end(): Promise<void> {
|
|
55
|
+
const dialog = this.dialog;
|
|
56
|
+
this.dialog = undefined;
|
|
57
|
+
await dialog?.end();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runs the query `build` returns in the session's dialog and reads all its pages.
|
|
62
|
+
*
|
|
63
|
+
* The query is built once the dialog is open, since building it takes the parameter data.
|
|
64
|
+
*/
|
|
65
|
+
async query<T>(build: () => Query<R, O, T>): Promise<T> {
|
|
66
|
+
const dialog = await this.open();
|
|
67
|
+
const query = build();
|
|
68
|
+
const pages = await this.fetchPages(dialog, query.order);
|
|
69
|
+
return parseBankData(() => query.read(pages));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Sends `order` in `dialog` and completes its approval. */
|
|
73
|
+
private async run(dialog: ProtocolDialog<R, O>, order: O): Promise<R> {
|
|
74
|
+
this.prompts.checkOpen();
|
|
75
|
+
try {
|
|
76
|
+
return await this.responses.complete(dialog, await dialog.send(order));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// The bank rejected the order and the dialog stays usable, or the bank ended the dialog or rejected the PIN and
|
|
79
|
+
// the dialog is discarded already. A cancelled or failed approval, or a broken connection, leaves the dialog in an
|
|
80
|
+
// unknown state.
|
|
81
|
+
if (!(error instanceof FinTsBankError)) await this.end();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sends the order built by `order` until the bank has delivered all pages (Formals B.6.3, "Aufsetzpunkt"). */
|
|
87
|
+
private async fetchPages(dialog: ProtocolDialog<R, O>, order: (continuation: string | undefined) => O): Promise<R[]> {
|
|
88
|
+
const pages: R[] = [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
let size = 0;
|
|
91
|
+
let continuation: string | undefined;
|
|
92
|
+
for (;;) {
|
|
93
|
+
const page = await this.run(dialog, order(continuation));
|
|
94
|
+
pages.push(page);
|
|
95
|
+
size += page.size;
|
|
96
|
+
const positions = [...continuations(page.feedback)];
|
|
97
|
+
// A message carries one order, so the bank names one position at most.
|
|
98
|
+
if (positions.length > 1) throw new FinTsProtocolError('The bank names several positions to continue at');
|
|
99
|
+
continuation = positions[0];
|
|
100
|
+
if (continuation === undefined) {
|
|
101
|
+
// Read as the last page, more data without a position would look like all there is.
|
|
102
|
+
if (hasCode(page.feedback, FeedbackCodes.moreData)) {
|
|
103
|
+
throw new FinTsProtocolError('The bank has more data, but names no position to continue at');
|
|
104
|
+
}
|
|
105
|
+
return pages;
|
|
106
|
+
}
|
|
107
|
+
// A repeated position or endless pages would keep the client busy forever, and unbounded pages exhaust memory.
|
|
108
|
+
if (seen.has(continuation) || pages.length === maxPages) {
|
|
109
|
+
throw new FinTsProtocolError('The bank does not stop sending pages');
|
|
110
|
+
}
|
|
111
|
+
seen.add(continuation);
|
|
112
|
+
if (size > maxQueryMebibytes * 1024 * 1024) {
|
|
113
|
+
throw new FinTsProtocolError(
|
|
114
|
+
`The bank sends more than ${maxQueryMebibytes} MiB for one query; ask for a shorter period`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { FinTsBankError, FinTsLoginDataChangedError, FinTsPinError, FinTsTanError } from '../core/errors.js';
|
|
2
|
+
import { allowedTanMethodsIn, FeedbackCodes, hasCode } from '../core/feedback.js';
|
|
3
|
+
import type { TanMethod } from '../tan/tan-models.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The kind of request a bank response answers, which decides what its errors mean for the login (`LoginGuard.judge`)
|
|
7
|
+
* and whether they can reject a TAN:
|
|
8
|
+
* - `login`: the login that opens a dialog;
|
|
9
|
+
* - `loginWithOrder`: a login that carries an order in the same message, whose errors may concern the order alone;
|
|
10
|
+
* - `order`: an order in a dialog the bank has opened;
|
|
11
|
+
* - `approval`: a message that completes an approval, the TAN or the question whether the user approved in the app.
|
|
12
|
+
*/
|
|
13
|
+
export type RequestKind = 'login' | 'loginWithOrder' | 'order' | 'approval';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A reason for a refused login that the session can take care of by itself, after which it logs in once more: the user
|
|
17
|
+
* may not use the TAN method the login was signed with, or the bank no longer accepts the customer system ID.
|
|
18
|
+
*/
|
|
19
|
+
export type RefusalReason = 'tanMethodNotAllowed' | 'systemIdRejected';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Keeps a retrying app from locking the user's access: once the bank has refused a login, the client does not send the
|
|
23
|
+
* PIN again.
|
|
24
|
+
*
|
|
25
|
+
* Banks lock the access after a few failed attempts, and a return code does not always tell whether the PIN was the
|
|
26
|
+
* reason. So `judge` counts as a refused login:
|
|
27
|
+
* - a rejected PIN, wherever the bank reports it;
|
|
28
|
+
* - any other error in the answer to a login, unless the login carries an order and the bank keeps the dialog open:
|
|
29
|
+
* then the error concerns the order.
|
|
30
|
+
*
|
|
31
|
+
* Errors in the answer to an order or an approval are no refusal, such as a wrong TAN at login: the bank checks the PIN
|
|
32
|
+
* before it asks for the approval. New login data (return code 3072) end the old ones for good (`refuse`).
|
|
33
|
+
*
|
|
34
|
+
* The guard fails closed: a refusal counts from the moment `judge` sees it, and only `pardon` lifts it, once the
|
|
35
|
+
* session has taken care of a `RefusalReason` the refusal names (`remediableReasons`). The protocol calls `check`
|
|
36
|
+
* before every message it signs with the PIN.
|
|
37
|
+
*/
|
|
38
|
+
export class LoginGuard {
|
|
39
|
+
private refusal: FinTsBankError | undefined;
|
|
40
|
+
|
|
41
|
+
/** The TAN method of the refused login; `undefined` without a refusal, and for new login data. */
|
|
42
|
+
private refusedMethodValue: TanMethod | undefined;
|
|
43
|
+
|
|
44
|
+
/** Throws the bank's refusal of an earlier login, so that the PIN is not sent again. */
|
|
45
|
+
check(): void {
|
|
46
|
+
if (this.refusal !== undefined) throw this.refusal;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether the guard holds a refusal. */
|
|
50
|
+
get hasRefusal(): boolean {
|
|
51
|
+
return this.refusal !== undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The TAN method the refused login was signed with; `undefined` where the guard holds no refusal of a login. */
|
|
55
|
+
get refusedMethod(): TanMethod | undefined {
|
|
56
|
+
return this.refusedMethodValue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether `error`, raised for the answer to a request of `kind` in a dialog signed with `method`, refuses the login;
|
|
61
|
+
* records it if it does.
|
|
62
|
+
*
|
|
63
|
+
* `dialogOpen` tells whether the bank keeps the dialog open after its answer.
|
|
64
|
+
*/
|
|
65
|
+
judge(error: FinTsBankError, kind: RequestKind, dialogOpen: boolean, method: TanMethod): boolean {
|
|
66
|
+
const refused = error instanceof FinTsPinError || kind === 'login' || (kind === 'loginWithOrder' && !dialogOpen);
|
|
67
|
+
if (refused && this.refusal === undefined) {
|
|
68
|
+
this.refusal = error;
|
|
69
|
+
this.refusedMethodValue = method;
|
|
70
|
+
}
|
|
71
|
+
return refused;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Records `error` as the reason the client logs in no more, unless it holds a refusal already. */
|
|
75
|
+
refuse(error: FinTsBankError): void {
|
|
76
|
+
this.refusal ??= error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs `lookup`, which only completes data, such as the SEPA account list; `undefined` where the bank rejects it, so
|
|
81
|
+
* that the caller carries on without it.
|
|
82
|
+
*
|
|
83
|
+
* A refused login is thrown, and so is a wrong TAN, which the user must hear of. An aborted dialog counts as a
|
|
84
|
+
* rejection: banks end the dialog along with many.
|
|
85
|
+
*/
|
|
86
|
+
async tryLookUp<T>(lookup: () => Promise<T>): Promise<T | undefined> {
|
|
87
|
+
try {
|
|
88
|
+
return await lookup();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!(error instanceof FinTsBankError) || this.isRefusal(error) || error instanceof FinTsTanError) throw error;
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The reasons the session can take care of in `error`: 3920 without the TAN method of the refused login, 9075 after
|
|
97
|
+
* the one-step method, and 9391. Empty if `error` is not the refusal the guard holds, rejects the PIN, or brings new
|
|
98
|
+
* login data.
|
|
99
|
+
*
|
|
100
|
+
* Only the return codes of the refusal itself count: TAN methods listed earlier say nothing about this login.
|
|
101
|
+
*/
|
|
102
|
+
remediableReasons(error: FinTsBankError): RefusalReason[] {
|
|
103
|
+
const method = this.refusedMethodValue;
|
|
104
|
+
if (
|
|
105
|
+
!this.isRefusal(error) ||
|
|
106
|
+
method === undefined ||
|
|
107
|
+
error instanceof FinTsPinError ||
|
|
108
|
+
error instanceof FinTsLoginDataChangedError
|
|
109
|
+
) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const feedback = error.feedback;
|
|
113
|
+
const allowed = allowedTanMethodsIn(feedback);
|
|
114
|
+
const reasons: RefusalReason[] = [];
|
|
115
|
+
if (
|
|
116
|
+
(allowed.length > 0 && !allowed.includes(method.securityFunction)) ||
|
|
117
|
+
(method.isOneStep && hasCode(feedback, FeedbackCodes.scaRequired))
|
|
118
|
+
) {
|
|
119
|
+
reasons.push('tanMethodNotAllowed');
|
|
120
|
+
}
|
|
121
|
+
if (hasCode(feedback, FeedbackCodes.newSystemIdRequired)) reasons.push('systemIdRejected');
|
|
122
|
+
return reasons;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Lifts `refusal` once the session has taken care of its reason, so that the next login may send the PIN. */
|
|
126
|
+
pardon(refusal: FinTsBankError): void {
|
|
127
|
+
if (!this.isRefusal(refusal)) return;
|
|
128
|
+
this.refusal = undefined;
|
|
129
|
+
this.refusedMethodValue = undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private isRefusal(error: unknown): boolean {
|
|
133
|
+
return error === this.refusal;
|
|
134
|
+
}
|
|
135
|
+
}
|