@coderoftime/fints
v0.3.0
Published
FinTS 3.0 client for German banks: accounts, balances and transactions with PIN/TAN, PSD2 strong customer authentication and decoupled app approval.
Downloads
475
Maintainers
Readme
@coderoftime/fints
A FinTS 3.0 client for German banks, written in TypeScript, without runtime dependencies. It reads accounts, balances and transactions with PIN/TAN, including PSD2 strong customer authentication and app approval (pushTAN, SecureGo plus and the like).
It is a port of the Dart package fints (see Differences from the Dart package). FinTS 4.1, which hardly any bank offers, is not part of the port.
Features
- PIN/TAN with the one-step method (
999) and two-step methods of process variant 2 (HKTANprocess 4, then 2 orS). - Strong customer authentication at login and per transaction where the bank asks for it, including exemptions (
3076). - App approval (decoupled), polled as the bank's parameters say, for 10 minutes at most; the client stops when the app is closed or the user cancels.
- TAN media lookup and selection, chipTAN flicker codes (HHD 1.3 and 1.4, as frames to animate) and photoTAN/QR images (PNG, JPEG, GIF, WebP or BMP, as
data:URLs for an<img>). - Balances (
HKSAL6–8) and transactions as camt.052 (HKCAZ) or MT940/MT942 (HKKAZ6–7), with automatic paging. - IBAN and BIC from the SEPA account list (
HKSPA), and the account extension of the user parameter data. - Safe by default: after a refused login the PIN is not sent again until the user enters it anew, also by a client restored from the saved state (see Refused logins); only what the bank's parameter data permit is sent; PIN and TAN are masked in every log.
- Saved sessions: bank and user parameters, customer system ID, the chosen TAN method and a refused login in one state string, which spares the setup steps at the next start.
- Parsers for MT940 (German structured
:86:fields, SEPA keywords) and camt.052/053/054, statement by statement with the account each is for (Mt940.parse,Camt.parse), or as one list of transactions (parseTransactions).
Requirements
- A FinTS product registration. German banks only accept registered products. Register for free with the Deutsche Kreditwirtschaft at https://www.fints.org/de/hersteller/produktregistrierung and pass the ID as
productId. Keep it out of your repository. - Your bank's FinTS URL and bank code (BLZ), for example from the DK institute list.
- A runtime with
fetch, Web Crypto andTextDecoder: Node.js from 22.12, Deno from 2.1, Bun from 1.2.6, or a browser behind a server of your own (see Using it with Vue). The type declarations use the globalURL,AbortSignalandfetchtypes: include the TypeScript libDOMor@types/node.
Usage
import { FinTs3Client, TanAnswer } from '@coderoftime/fints';
const client = new FinTs3Client({
config: {
url: 'https://fints.example-bank.de/fints',
bankCode: '12345678',
userId: 'my-login',
productId: process.env.FINTS_PRODUCT_ID!,
productVersion: '1.0',
},
pin,
options: {
state: savedState, // from a previous session, or undefined
tanMethodSelector: (methods) => pickMethod(methods),
tanMediumSelector: (media) => pickMedium(media),
onBankMessage: (message) => showNotice(message.subject, message.text),
onWarning: (warning) => showNotice(warning.code, warning.text), // banks want code and text shown
tanCallback: async (challenge) => {
if (challenge.isDecoupled) {
// Close the prompt when challenge.resolved settles; the bank's new instructions come with the next call.
// Offer "approved" only if challenge.manualConfirmation.
const confirmed = await showApprovalPrompt(challenge);
return confirmed ? TanAnswer.approved : TanAnswer.cancelled;
}
// Show challenge.plainMessage as text (textContent, never innerHTML), challenge.image?.toDataUrl() in an
// <img>, or challenge.flickerCode?.frames().
const tan = await askForTan(challenge);
return tan === undefined ? TanAnswer.cancelled : TanAnswer.tan(tan);
},
},
});
try {
const accounts = await client.getAccounts();
const balance = await client.getBalance(accounts[0]!);
const transactions = await client.getTransactions(accounts[0]!, { from: new Date(2024, 0, 1) });
} finally {
try {
await client.close();
} finally {
saveEncrypted(client.state); // after close, whose answers may change it; no PIN or TAN, but login and account data
}
}A call logs in where it needs to, which sends the PIN and may ask the callbacks for a TAN method, a TAN medium and an approval; so does getTanMethods of a client without saved state. Calls share one bank dialog while it is open, so the approval of its login serves them all. The next call logs in anew, which may ask for another approval, once the dialog has idled longer than the bank allows (BankInfo.maxTimeout, 5 minutes where it states none), after selectTanMethod and clearLoginRefusal, once the bank has ended the dialog, and after a message failed with another error than a FinTsBankError, such as a cancelled or timed-out approval, a failing tanCallback, or a lost or unreadable answer. A query may need an approval of its own, such as one for transactions older than 90 days, and so may up to three of its further pages; where a fourth asks for one, the call fails with FinTsProtocolError before the user is asked.
Calls run one after another. A callback must not wait for another call of its client, which would wait for the running call: on Node.js, Deno and Bun such a call fails with FinTsStateError, in browsers it waits forever.
A saved state belongs to one client at a time. Run the clients of one bank user one after another, each restored from the state the one before saved, for example behind a lock or a queue per user, never two at once from the same state, such as for two browser tabs: the state saved last wins, and could lift a refused login the other client saved.
Never log the client: JSON.stringify and Node.js's console.log leave out the PIN, which the client keeps in a private field until it is closed or the login refused, but a debugger and the browser's DevTools show it.
console.log and util.inspect on Node.js, Deno and Bun show the objects of this package only by what identifies no person, account or TAN: codes, kinds, counts, dates and the last digits of an IBAN or card number, such as Account(…3000, Girokonto), TanChallenge(TanMethod(942, pushTAN), decoupled, attempt 1) or FinTsFeedback(9010). They leave out the bank's texts, references and parameters, TAN media names, TANs, challenge data and images, login data, and the amounts of balances and transactions; an Amount on its own shows as its toString, such as -123.45 EUR. toString of a FinTsFeedback and of a BankMessage leaves out the bank's texts as well: show code and text, or subject and text, to the user. An error shows its message and stack, and a FinTsTransportError its statusCode, also where Node.js reports it uncaught; the message of a FinTsBankError, and so its stack, holds the bank's texts and parameters of its errors, such as an IBAN, so log its codes instead. JSON.stringify and the browser console show every field, IBANs, names, amounts, purposes and the TAN of a TanAnswer included, so that a server can pass the data to its own front end: neither belongs in logs. Only JSON.stringify of an error leaves out the feedback of a FinTsBankError and the source of a FormatError.
An account the app builds itself, such as new Account({ bankCode, iban }), stands for the account of getAccounts it matches by IBAN or account number, whose details and permitted transactions the queries take. One the bank names nowhere goes out with what identifies it only, and fails with FinTsUnsupportedError where the bank's lists of permitted transactions are complete.
Calendar dates, such as Transaction.bookingDate, are Dates at local midnight, of which only the day counts. Moments, such as Balance.bookedAsOfTime, are exact; the bank states them in German time.
Using it with Vue
A web page cannot talk to a bank's FinTS server directly. Browsers only let a page call another origin if the server allows it (CORS), and bank servers do not. So some server of yours always sits between the browser and the bank, and that server sees the PIN: FinTS PIN/TAN messages carry it in plain text inside TLS. It must be a server you and your users trust.
There are three ways to set it up:
The client runs on your server (Nuxt server routes, Express, …). The Vue app calls your own API; the server keeps one
FinTs3Clientper session and passes the TAN challenges to the browser, for example with a pending request the browser answers. Two sessions of one bank user share the saved state, so run their clients one after another (see Usage). This keeps the bank traffic and the product ID on the server.The client runs in the browser, through a relay. The library runs in the Vue app, so TAN prompts are plain local UI, and a small endpoint of yours forwards each message to the bank. Pass a transport that posts to it:
import { FinTsTransportError, type FinTsTransport } from '@coderoftime/fints'; const relay: FinTsTransport = { async post(url, body, contentType, signal) { const response = await fetch('/api/fints-relay', { method: 'POST', headers: { 'Content-Type': contentType, 'X-FinTS-URL': url.href }, body: body.slice(), // a copy on an ArrayBuffer of its own, the only kind TypeScript's fetch types take signal, // aborted five seconds after the client is closed redirect: 'error', // never send the PIN on to another address cache: 'no-store', }); if (!response.ok) { // The bank's HTTP status, which the relay passes on and the client acts on (FinTsTransport.post). throw new FinTsTransportError(`Relay: HTTP ${response.status}`, { statusCode: response.status }); } return new Uint8Array(await response.arrayBuffer()); }, close() {}, };Until it is closed, the client limits neither the time of an exchange nor the size of an answer, so a transport of your own has to, unless it goes through a relay that does, as this one does. The relay forwards with
HttpTransport, which times out, limits the size of the answer and follows no redirect, and passes the bank's HTTP status on. A sketch with the web-standardRequestandResponse:import { FinTsTransportError, HttpTransport } from '@coderoftime/fints'; const bank = new HttpTransport(); const maxBodyBytes = 1024 * 1024; // far more than a message of the client takes // Behind the app's login and a rate limit per session; bankUrl is the bank the user chose, as URL.href writes it. export async function relay(request: Request, session: { bankUrl: string }): Promise<Response> { if (request.headers.get('X-FinTS-URL') !== session.bankUrl) return new Response(null, { status: 403 }); // Checked before the body is read, of which the server reads no more than the length states. const length = Number(request.headers.get('Content-Length') ?? NaN); if (!(length <= maxBodyBytes)) return new Response(null, { status: 413 }); try { const body = new Uint8Array(await request.arrayBuffer()); const answer = await bank.post(new URL(session.bankUrl), body, 'text/plain', request.signal); return new Response(answer.slice(), { headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' } }); } catch (error) { // The bank's HTTP status, which the client acts on; 502 where the bank did not answer. const status = error instanceof FinTsTransportError ? error.statusCode : undefined; return new Response(null, { status: status ?? 502 }); } }Anyone who can call the relay can send logins to the banks it forwards to, from your server's address, which a bank may then block for all your users. So serve it to the signed-in sessions of your app only, limit the requests of each session, and forward only to the bank the user chose for the session, from a list of known https URLs such as the DK institute list. The body holds PIN and TAN: never log it, on either side, nor put it in an error message, which the client passes on. And never mix up the answers of two sessions: the client cannot tell the answer to the first message of a dialog from that of another user. The product ID ends up in the page; anyone can read it there.
The app is not a web page: Electron, Tauri or Capacitor can send requests without CORS. Pass a
FinTsTransportthat uses the platform's HTTP client without following redirects, with a timeout and a limit on the size of the answer, orHttpTransportwherefetchis not bound by CORS.
Keep a client in the Vue app out of reactive state (ref, reactive, a Pinia store): Vue wraps it in a proxy there, which cannot reach the client's private fields, so every call fails with a TypeError. A plain variable, a shallowRef or markRaw(client) keeps the client as it is.
A TAN prompt in Vue fits the callback as a promise the dialog resolves:
import { ref } from 'vue';
import { TanAnswer, type TanChallenge } from '@coderoftime/fints';
export const pending = ref<{ challenge: TanChallenge; answer: (a: TanAnswer) => void }>();
export function tanCallback(challenge: TanChallenge): Promise<TanAnswer> {
return new Promise((answer) => {
pending.value = { challenge, answer };
void challenge.resolved?.then(() => (pending.value = undefined)); // the client needs no answer any more
});
}
// The dialog component shows pending.value.challenge and calls pending.value.answer(TanAnswer.tan(input)),
// TanAnswer.approved or TanAnswer.cancelled, then clears pending.value.The dialog shows the challenge's text with {{ }}, which Vue escapes, never with v-html: plainMessage and message, like any text of the bank, are no markup to trust, and as HTML they could run scripts in the page where the user enters the PIN. The same holds for the bank messages and warnings of onBankMessage and onWarning, whose number and length the bank chooses without a limit, so bound what the app shows of them. white-space: pre-line keeps the line breaks of plainMessage:
<div v-if="pending">
<p style="white-space: pre-line">{{ pending.challenge.plainMessage }}</p>
<img v-if="pending.challenge.image" :src="pending.challenge.image.toDataUrl()" alt="TAN image" />
</div>Errors
| Error | Meaning |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| FinTsLoginRefusedError | The bank refused the login. The client sends the PIN no more, see Refused logins. |
| FinTsPinError | Wrong or blocked PIN, a FinTsLoginRefusedError. |
| FinTsTanError | Wrong, expired or used TAN. The session stays usable. |
| FinTsTanCancelledError | The user cancelled the TAN or the app approval. |
| FinTsDecoupledTimeoutError | The app approval did not come within the requests the bank allows (60 where it states none) or 10 minutes. |
| FinTsLoginDataChangedError | The bank issued new login data (3072); loginName tells it. A FinTsLoginRefusedError for the old data. |
| FinTsDialogAbortedError | The bank ended the dialog; the next call logs in anew. |
| FinTsBankError | Any other rejection; feedback holds the return codes. |
| FinTsUnsupportedError | The parameter data do not offer the transaction; the client did not send it, though it may have logged in. |
| FinTsTanSetupError | No TAN method or medium to log in with; pass a medium to selectTanMethod, or choose another method. |
| FinTsProtocolError | The bank's answer could not be read. The message leaves out the data, which may be account data. |
| FinTsTransportError | No connection, an HTTP error (statusCode), a timeout or an abort by close. The message may have reached the bank. |
| FinTsStateError | The client was closed, or called from inside one of its callbacks. |
| TypeError | An invalid argument, such as an empty PIN or a TAN that the TAN method rules out; the message never shows PIN or TAN. |
The client never repeats a call by itself: the bank's answer cannot tell whether an order went through.
It reads only complete answers to the message it sent: one cut short, broken, or with the number or dialog ID of another message fails the call with FinTsProtocolError. Only the bank's dialog abort message, which names neither (dialog ID unbekannt, message number 9999), answers any message: it fails the call with a FinTsBankError that holds the bank's text, and ends the dialog. The first message of every dialog goes out with the same number and dialog ID, though, so the answer to another user's first message passes: a relay must never mix up answers. A query whose pages hold more than 128 MiB, each data element counted as 512 bytes, or more than 200,000 statements and transactions, fails with FinTsProtocolError as well: ask for a shorter period then. So that a broken or hostile server cannot block the app or exhaust its memory, the client also refuses an answer with more than 1,000,000 data elements and segments in its plain or in its encrypted part, or with more than one encrypted part; XML nested more than 100 levels deep, or with more than 500,000 nodes besides text or one per 16 characters, whichever is more; an MT940 field or camt remittance information (Ustrd) of more than 65,536 characters; and an amount written with more than 64 characters. Mt940.parse, Camt.parse and Amount.parseDecimal throw FormatError for these.
MT940 statements must balance. Where a bank wraps a purpose at a fixed width, a payer could start a line of it like a field, such as :61:, which would add a transaction; getTransactions fails with FinTsProtocolError instead, and Mt940.parse with FormatError. The check misses such lines that add up to nothing, and statements without balances, such as pending transactions (MT942).
Refused logins
Banks lock the online access after a few failed logins. So once the bank has refused a login, the client does not send the PIN again: every call that would fails with the refusal, a FinTsLoginRefusedError, without contacting the bank. Any error in the answer to a login refuses it, since a return code does not always tell whether the PIN was the reason, and so does a wrong PIN the bank reports later in the dialog.
Only where the bank names a reason the client can take care of does the same call log in once more, once per reason: with another TAN method where the bank lists those the user may use without the one the login was signed with (3920, after the one-step method only next to no error but 9050, 9800 or 9955) or demands strong authentication of a one-step login (9075), and with a new customer system ID where the bank rejects the old one (9391). None of them follows a wrong PIN, and a call sends at most three logins the bank refuses.
A login whose answer is lost, times out, is aborted by close or cannot be read may have counted as a failed attempt all the same. After two such logins in a row the client refuses the login itself, with a FinTsLoginRefusedError whose feedback is empty; it cannot tell a login that never reached the bank, so a connection that fails twice in a row stops it as well. A login the bank takes clears the count.
The refusal and that count are part of client.state, so a client restored from it does not send the PIN either, as long as the clients of the user take turns with the state (see Usage); where their part of the state is damaged, the client restores a refusal whose feedback is empty. client.loginRefusal tells it before any call. Lift it with a PIN the user has entered anew, unless the bank has changed the login data:
const refusal = client.loginRefusal;
if (refusal instanceof FinTsLoginDataChangedError) {
// The old login data are retired: build a new client with refusal.loginName as userId, or its userId and customerId.
} else if (refusal !== undefined) {
showNotice(refusal.message); // the bank's errors, or why the client stopped by itself
await client.clearLoginRefusal(await askForPin());
}Never pass clearLoginRefusal a stored PIN without asking the user: that is how a retrying app locks the access. It also forgets the TAN medium, which the bank may have refused instead, such as that of a replaced phone, so that the next call looks the media up anew.
Differences from the Dart package
This section compares with the Dart package as its source stands after its release 0.2.0, which differs in more ways. Both keep the saved state in the same layout, a refused login included, so each restores the other's. The Dart package 0.2.0 keeps no refused login, though: restored from a state of this package, it sends the PIN the bank refused. They differ in:
- Errors are named
…Errorinstead of…Exception; invalid arguments areTypeErrors, malformed dataFormatErrors, and a closed client or a call from inside a callback aFinTsStateError. Some messages are worded differently. getTransactions(account, { from, to, format })takes an options object; enums are string unions such as'mt940'.- The polling parameters of
TanMethodare in seconds:decoupledFirstPollDelaySeconds,decoupledPollIntervalSeconds. - No FinTS 4.1, so no
FinTs4Client,FinTsClient.versionorBankInfo.bic.FinTsConfigchecks the login data against what FinTS 3.0 can send, which the Dart package does inFinTs3Client, and also refuses a user ID or customer ID of more than 30 characters, which the Dart package sends. HttpTransporttakes afetchandtimeoutMsinstead of an HTTP client and aDuration.FinTsTransport.posttakes anAbortSignal, with whichcloseaborts a request on its way after five seconds, also through a custom transport, and then stops waiting for it whatever the transport does; the Dart package aborts only its ownHttpTransport(abortRequests) and waits for any other transport up to that transport's timeout.- HTTP goes through the runtime's
fetch, with its proxies, timeouts and trusted certificates. Plain http to a local server, whichallowInsecureUrlallows, goes wherever the globalfetchsends it, as https does: to whatever replacesglobalThis.fetch, such as a test's stub, to a global dispatcher on Node.js, and through any proxy thatfetchtakes from the environment (Node.js withNODE_USE_ENV_PROXY=1, Deno and Bun withHTTP_PROXY), which sees the PIN. Exempt the host withNO_PROXY=localhost,127.0.0.1,::1or pass afetchthat connects directly. The Dart package connects to the loopback host directly, also bypassing a client set withrunWithClient, and on native platforms followsHTTPS_PROXYandNO_PROXYfor https, which Node.js does only withNODE_USE_ENV_PROXY=1. - A call from inside a callback fails on Node.js, Deno and Bun only; in browsers it waits forever, where the Dart package fails on every platform.
console.logandutil.inspectshow the objects of this package as Usage describes, where the Dart package prints most of them asInstance of '…', andtoStringof aFinTsFeedbackor aBankMessageleaves out the bank's texts, which the Dart package prints. The browser console shows every field, where the Dart package on the web printstoString.JSON.stringifygives every field of a model, the TAN of aTanAnswerincluded, and of an error itsname, and thestatusCodeof aFinTsTransportError; the Dart package's models and errors have no JSON form.- Some bank data are read and sent otherwise. In MT940, the counterparty's IBAN and BIC come from the subfields
?31and?30only, not fromIBAN+andBIC+in the payer's reference, a bank code in?30next to an IBAN is the bank code, the customer referenceKREF+stands for the one of:86:, and the balance date0or000000for none. A camt purpose falls back to the structured creditor reference, and remittance information of more than 65,536 characters is refused. Challenge text counts as a combined chipTAN challenge only where it starts withCHLGUCand states the length of its code. The bank's dialog abort message fails the call with aFinTsBankError. A balance or transaction query that names the account by its international account (KTI) leaves out the national account where the bank parameter data rule it out, unless the account has no BIC, and sends no IBAN without its BIC, unless the account has no account number. A one-step login that the bank answers with9955and3920is repeated with a two-step method.
Development
npm install
npm run check # type check, lint, unused code, format check and tests with coverage
npm run buildThe check fails for Node.js APIs in src/ that browsers lack, for files, exports and dependencies nothing uses, or only
the tests (knip), and for coverage below the thresholds in vitest.config.ts, which each module
that guards what goes to and comes from the bank has to reach on its own. The coverage report is in
coverage/index.html.
License
BSD 3-Clause, see LICENSE. Commercial use is allowed; a product that ships the library must reproduce its copyright notice and license in its documentation or notices.
