diff --git a/.gitignore b/.gitignore index c06230a..2b01b9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # OS specific .DS_Store +# IDE +.vscode +.idea + # Build directory lib @@ -37,9 +41,6 @@ build/Release node_modules jspm_packages -# -*package-lock.json - # Optional npm cache directory .npm diff --git a/README.md b/README.md index f2031f1..47e2375 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,32 @@ npm install --save bankid yarn install bankid ``` -## Usage +## Usage V6 + +```javascript +import { BankIdClientV6 } from "bankid"; + +const client = new BankIdClientV6({ + production: false, +}); + +const { autoStartToken, orderRef } = await client.authenticate({ + endUserIp: "127.0.0.1", +}); + +// Generate deep link from autoStarttoken and try to open BankID app +// See ./examples + +client + .awaitPendingCollect(orderRef) + .then(res => { + console.log(res.completionData) + }) + +``` +Acting on a session is done trough opening the app or trough scanning a QR Code, both examples are documented in detail [in the examples directory](./examples) + +## Usage V5 ```javascript import { BankIdClient } from "bankid"; diff --git a/examples/auth-simple.mjs b/examples/auth-simple.mjs index 548ce96..4c1740c 100644 --- a/examples/auth-simple.mjs +++ b/examples/auth-simple.mjs @@ -1,9 +1,9 @@ -import {BankIdClient} from "../lib/bankid.js"; +import { BankIdClient } from "../lib/bankid.js"; const personalNumber = process.argv[2]; -const bankid = new BankIdClient({production: false}); +const bankid = new BankIdClient({ production: false }); bankid - .authenticateAndCollect({endUserIp: "127.0.0.1", personalNumber}) + .authenticateAndCollect({ endUserIp: "127.0.0.1", personalNumber }) .then(res => console.log(res.completionData.user)) .catch(err => console.error(err)); diff --git a/examples/cancel.mjs b/examples/cancel.mjs index ec54804..1d306af 100644 --- a/examples/cancel.mjs +++ b/examples/cancel.mjs @@ -1,12 +1,15 @@ -import {BankIdClient} from "../lib/bankid.js"; +import { BankIdClient } from "../lib/bankid.js"; const personalNumber = process.argv[2]; -const bankid = new BankIdClient({production: false}); +const bankid = new BankIdClient({ production: false }); async function testCancelation() { - const { orderRef } = await bankid.authenticate({endUserIp: "127.0.0.1", personalNumber}); + const { orderRef } = await bankid.authenticate({ + endUserIp: "127.0.0.1", + personalNumber, + }); await bankid - .cancel({orderRef}) + .cancel({ orderRef }) .then(() => console.log("success")) .catch(console.error); } diff --git a/examples/collect.mjs b/examples/collect.mjs index d41ff60..a2e81fc 100644 --- a/examples/collect.mjs +++ b/examples/collect.mjs @@ -1,16 +1,21 @@ -import {BankIdClient} from "../lib/bankid.js"; +import { BankIdClient } from "../lib/bankid.js"; const personalNumber = process.argv[2]; -const bankid = new BankIdClient({production: false}); +const bankid = new BankIdClient({ production: false }); bankid - .sign({endUserIp: "127.0.0.1", personalNumber, userVisibleData: "visible", userNonVisibleData: "invisible"}) + .sign({ + endUserIp: "127.0.0.1", + personalNumber, + userVisibleData: "visible", + userNonVisibleData: "invisible", + }) .then(res => { const timer = setInterval(() => { const done = () => clearInterval(timer); bankid - .collect({orderRef: res.orderRef}) + .collect({ orderRef: res.orderRef }) .then(res => { console.log(res.status); diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..9bfb134 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,20 @@ + + + BankID QR code + + + + + + + + \ No newline at end of file diff --git a/examples/v6-autostart.mjs b/examples/v6-autostart.mjs new file mode 100644 index 0000000..ac9006e --- /dev/null +++ b/examples/v6-autostart.mjs @@ -0,0 +1,32 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { BankIdClientV6 } from "../lib/bankid.js"; + +const execPromise = promisify(exec); + +const bankid = new BankIdClientV6({ + production: false, +}); + +const tryOpenBankIDDesktop = async (autoStartToken, redirectUrl) => { + const deepLink = `bankid:///?autostarttoken=${autoStartToken}&redirect=${redirectUrl}`; + await execPromise(`open "${deepLink}"`); +}; + +/** + * The main function initiates a BankID authentication flow. + * It automatically starts the BankID application on the user's device if installed. + */ +const main = async () => { + const { autoStartToken, orderRef } = await bankid.authenticate({ + endUserIp: "127.0.0.1", + }); + const redirectUrl = `https://www.google.com`; + console.log(`Trying to trigger bankid on your current device..`); + await tryOpenBankIDDesktop(autoStartToken, redirectUrl); + console.log("Awaiting sign.."); + const resp = await bankid.awaitPendingCollect(orderRef); + console.log("Succes!", resp); +}; + +main().catch(err => console.error(err)); diff --git a/examples/v6-qrcode-customcache.mjs b/examples/v6-qrcode-customcache.mjs new file mode 100644 index 0000000..d966cb6 --- /dev/null +++ b/examples/v6-qrcode-customcache.mjs @@ -0,0 +1,68 @@ +/** + * This script uses the BankId API to authenticate. Results are logged to the console. + * The script will keep generating new QR codes for authentification for a + * maximum of 20 seconds, while continuously checking the order status. + * In case the order status still has not turned "complete" after 20 seconds, + * the script will timeout + */ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { BankIdClientV6 } from "../lib/bankid.js"; +import { cwd } from "node:process"; + +const execPromise = promisify(exec); + +const customCache = { + cache: {}, + get(key) { + console.log("get called! ", key); + return this.cache[key]; + }, + set(key, value) { + console.log("set called!"); + this.cache[key] = value; + }, +}; + +const bankid = new BankIdClientV6({ + production: false, + qrOptions: { customCache }, +}); + +const tryOpenQRCodeInBrowser = async code => { + // Apple way of opening html files with GET params + await execPromise( + `osascript -e 'tell application "Google Chrome" to open location "file://${cwd()}/index.html?code=${encodeURIComponent( + code, + )}"'`, + ); +}; + +const main = async () => { + const { orderRef, qr } = await bankid.authenticate({ + endUserIp: "127.0.0.1", + }); + + let success = false; + // Generate new QR code for 20 seconds, check status of the order on each cycle + for await (const newQrCode of qr.nextQr(orderRef, { timeout: 20 })) { + tryOpenQRCodeInBrowser(newQrCode); + const resp = await bankid.collect({ orderRef }); + console.log({ orderRef, newQrCode }); + if (resp.status === "complete") { + // Check for success ? + success = true; + console.log("Succes!", resp); + return; + } else if (resp.status === "failed") { + throw new Error(resp); + } + + await new Promise(r => setTimeout(r, 2000)); + } + if (!success) { + console.log("Timeout! Nothing happened"); + } +}; + +main().catch(err => console.error(err)); diff --git a/examples/v6-qrcode.mjs b/examples/v6-qrcode.mjs new file mode 100644 index 0000000..e426139 --- /dev/null +++ b/examples/v6-qrcode.mjs @@ -0,0 +1,52 @@ +/** + * This script uses the BankId API to authenticate. Results are logged to the console. + * The script will keep generating new QR codes for authentification for a + * maximum of 20 seconds, while continuously checking the order status. + * In case the order status still has not turned "complete" after 20 seconds, + * the script will timeout + */ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { BankIdClientV6 } from "../lib/bankid.js"; +import { cwd } from "node:process"; + +const execPromise = promisify(exec); + +const bankid = new BankIdClientV6({ production: false }); + +const tryOpenQRCodeInBrowser = async code => { + // Apple way of opening html files with GET params + await execPromise( + `osascript -e 'tell application "Google Chrome" to open location "file://${cwd()}/index.html?code=${encodeURIComponent( + code, + )}"'`, + ); +}; + +const main = async () => { + const { orderRef, qr } = await bankid.authenticate({ + endUserIp: "127.0.0.1", + }); + + let success = false; + // Generate new QR code for 20 seconds, check status of the order on each cycle + for await (const newQrCode of qr.nextQr(orderRef, { timeout: 20 })) { + tryOpenQRCodeInBrowser(newQrCode); + const resp = await bankid.collect({ orderRef }); + if (resp.status === "complete") { + // Check for success ? + success = true; + console.log("Succes!", resp); + return; + } else if (resp.status === "failed") { + throw new Error(resp); + } + + await new Promise(r => setTimeout(r, 2000)); + } + if (!success) { + console.log("Timeout! Nothing happened"); + } +}; + +main().catch(err => console.error(err)); diff --git a/package.json b/package.json index a151fd2..b5234d0 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,17 @@ "bankid", "authentication" ], - "version": "3.1.4", - "main": "lib/bankid.js", + "version": "3.2.0", + "main": "lib/index.js", "repository": { "type": "git", "url": "git+https://github.com/anyfin/bankid.git" }, "scripts": { + "dev": "tsc --watch", "build": "tsc", "prepublishOnly": "yarn build", - "format": "prettier --write \"./**/*.{js,ts,json,md}\"" + "format": "prettier --write \"./**/*.{mjs,js,ts,json,md}\"" }, "author": "Sven Perkmann", "license": "MIT", diff --git a/src/bankid.ts b/src/bankid.ts index 713859d..4d69e4f 100644 --- a/src/bankid.ts +++ b/src/bankid.ts @@ -4,12 +4,13 @@ import * as path from "path"; import type { AxiosInstance } from "axios"; import axios from "axios"; +import { QrGenerator, QrGeneratorOptions } from "./qrgenerator"; // // Type definitions for /auth // -export interface AuthRequest { +export interface AuthRequestV5 { endUserIp: string; personalNumber?: string; requirement?: AuthOptionalRequirements; @@ -19,6 +20,10 @@ export interface AuthRequest { } export interface AuthResponse { + /** + * Use in deeplink to start BankId on same device. + * @example `bankid:///?autostarttoken=[TOKEN]&redirect=[RETURNURL]` + */ autoStartToken: string; qrStartSecret: string; qrStartToken: string; @@ -37,7 +42,7 @@ interface AuthOptionalRequirements { // Type definitions for /sign // -export interface SignRequest extends AuthRequest { +export interface SignRequest extends AuthRequestV5 { userVisibleData: string; } @@ -51,7 +56,8 @@ export interface CollectRequest { orderRef: string; } -export interface CollectResponse { +type CollectResponse = CollectResponseV5 | CollectResponseV6; +export interface CollectResponseV5 { orderRef: string; status: "pending" | "failed" | "complete"; hintCode?: FailedHintCode | PendingHintCode; @@ -132,7 +138,7 @@ export enum BankIdMethod { } export type BankIdRequest = - | AuthRequest + | AuthRequestV5 | SignRequest | CollectRequest | CancelRequest; @@ -141,7 +147,8 @@ export type BankIdResponse = | CancelResponse | AuthResponse | SignResponse - | CollectResponse; + | CollectResponseV5 + | CollectResponseV6; // // Client settings @@ -191,8 +198,9 @@ export class RequestError extends Error { export class BankIdClient { readonly options: Required; - readonly axios: AxiosInstance; - readonly baseUrl: string; + axios: AxiosInstance; + + version = "v5.1"; constructor(options?: BankIdClientSettings) { this.options = { @@ -229,14 +237,11 @@ export class BankIdClient { : path.resolve(__dirname, "../cert/", "test.ca"); } - this.axios = this.#createAxiosInstance(); - - this.baseUrl = this.options.production - ? "https://appapi2.bankid.com/rp/v5.1/" - : "https://appapi2.test.bankid.com/rp/v5.1/"; + this.axios = this.createAxiosInstance(); + return this; } - authenticate(parameters: AuthRequest): Promise { + authenticate(parameters: AuthRequestV5): Promise { if (!parameters.endUserIp) { throw new Error("Missing required argument endUserIp."); } @@ -257,7 +262,10 @@ export class BankIdClient { : undefined, }; - return this.#call(BankIdMethod.auth, parameters); + return this.#call( + BankIdMethod.auth, + parameters, + ); } sign(parameters: SignRequest): Promise { @@ -306,7 +314,7 @@ export class BankIdClient { } async authenticateAndCollect( - parameters: AuthRequest, + parameters: AuthRequestV5, ): Promise { const authResponse = await this.authenticate(parameters); return this.awaitPendingCollect(authResponse.orderRef); @@ -345,7 +353,7 @@ export class BankIdClient { ): Promise { return new Promise((resolve, reject) => { this.axios - .post(this.baseUrl + method, payload) + .post(method, payload) .then(response => { resolve(response.data); }) @@ -368,7 +376,11 @@ export class BankIdClient { }); } - #createAxiosInstance(): AxiosInstance { + createAxiosInstance(): AxiosInstance { + const baseURL = this.options.production + ? `https://appapi2.bankid.com/rp/${this.version}/` + : `https://appapi2.test.bankid.com/rp/${this.version}/`; + const ca = Buffer.isBuffer(this.options.ca) ? this.options.ca : fs.readFileSync(this.options.ca, "utf-8"); @@ -378,6 +390,7 @@ export class BankIdClient { const passphrase = this.options.passphrase; return axios.create({ + baseURL, httpsAgent: new https.Agent({ pfx, passphrase, ca }), headers: { "Content-Type": "application/json", @@ -385,3 +398,93 @@ export class BankIdClient { }); } } + +interface AuthOptionalRequirementsV6 { + pinCode: boolean; + cardReader?: "class1" | "class2"; + mrtd: boolean; + certificatePolicies?: string[]; + personalNumber: string; +} + +export interface AuthRequestV6 { + endUserIp: string; + requirement?: AuthOptionalRequirementsV6; +} + +interface AuthResponseV6 extends AuthResponse { + qr?: QrGenerator; +} + +interface SignResponseV6 extends SignResponse { + qr?: QrGenerator; +} + +export interface CompletionDataV6 { + user: { + personalNumber: string; + name: string; + givenName: string; + surname: string; + }; + device: { + ipAddress: string; + uhi?: string; + }; + /** ISO 8601 date format YYYY-MM-DD with a UTC time zone offset. */ + bankIdIssueDate: string; + stepUp: boolean; + signature: string; + ocspResponse: string; +} + +export interface CollectResponseV6 + extends Omit { + completionData?: CompletionDataV6; +} + +interface BankIdClientSettingsV6 extends BankIdClientSettings { + /** Controls whether to attach an instance of {@link QrGenerator} to BankID responses */ + qrEnabled?: boolean; + qrOptions?: QrGeneratorOptions; +} + +/** + * A class for creating a BankId Client based on v6.0 api, extending from BankIdClient + * @see https://www.bankid.com/en/utvecklare/guider/teknisk-integrationsguide/webbservice-api + */ +export class BankIdClientV6 extends BankIdClient { + version = "v6.0"; + options: Required; + + constructor(options: BankIdClientSettingsV6) { + super(options); + this.axios = this.createAxiosInstance(); + this.options = { + // @ts-expect-error this.options not typed after super() call. + ...(this.options as Required), + qrEnabled: options.qrEnabled ?? true, + qrOptions: options.qrOptions ?? QrGenerator.defaultOptions, + }; + } + + async authenticate(parameters: AuthRequestV6): Promise { + const resp = await super.authenticate(parameters); + const qr = this.options.qrEnabled + ? new QrGenerator(resp, this.options.qrOptions) + : undefined; + return { ...resp, qr }; + } + + async sign(parameters: SignRequest): Promise { + const resp = await super.sign(parameters); + const qr = this.options.qrEnabled + ? new QrGenerator(resp, this.options.qrOptions) + : undefined; + return { ...resp, qr }; + } + + async collect(parameters: CollectRequest) { + return super.collect(parameters) as Promise; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..98f5c59 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./bankid"; +export * from "./qrgenerator"; diff --git a/src/qrgenerator.ts b/src/qrgenerator.ts new file mode 100644 index 0000000..0355557 --- /dev/null +++ b/src/qrgenerator.ts @@ -0,0 +1,145 @@ +import { createHmac } from "node:crypto"; +import type { AuthResponse } from "./bankid"; + +export interface QrCacheEntry { + startTime: number; + qrStartToken: string; + qrStartSecret: string; +} + +interface QRGenerateOptions { + /** max cycles */ + maxCycles?: number; + /** in seconds */ + timeout?: number; +} + +export type QrGeneratorOptions = + | { + /** Provide your own caching layer */ + customCache: QrGeneratorCache; + } + | { + orderTTL: number; + }; + +/** + * Default in-memory cache for storing qr payloads + * based on `orderRef`. + */ +const _defaultCacheMap = new Map(); +const defaultCache = { + get: (key: string) => Promise.resolve(_defaultCacheMap.get(key)), + set: (key: string, value: QrCacheEntry) => + Promise.resolve(_defaultCacheMap.set(key, value)).then(() => void 0), + delete: (key: string) => Promise.resolve(_defaultCacheMap.delete(key)), +}; + +export type QrGeneratorCache = typeof defaultCache; + +/** seconds */ +const TIMEOUT = 60 as const; + +/** + * QrGenerator is an optional class responsible for generating QR codes based + * on bankID responses and caching them with its custom cache store. + * It has functionalities to generate and retrieve the latest QR code + * from cache and cycle through a new QR code value. + */ +export class QrGenerator { + cache: QrGeneratorCache = defaultCache; + orderRef: string | null; + + static defaultOptions = { orderTTL: TIMEOUT } as const; + + constructor( + resp: AuthResponse | null, + options: QrGeneratorOptions = QrGenerator.defaultOptions, + ) { + if ("customCache" in options && Boolean(options.customCache)) { + this.cache = options.customCache; + } + + this.orderRef = resp?.orderRef || null; + // If constructed with a response, set the cache + if (resp) { + const { qrStartSecret, qrStartToken, orderRef } = resp; + const now = Date.now(); + const qrCacheEntry: QrCacheEntry = { + startTime: now, + qrStartSecret, + qrStartToken, + }; + this.cache.set(orderRef, qrCacheEntry); + } + + // local in-memory cache will auto-clean keys after set TTL + if ("orderTTL" in options) { + setTimeout(() => { + if (this.orderRef) { + this.cache.delete(this.orderRef); + } + }, options.orderTTL * 1000); + } + return this; + } + + /** + * latestQrFromCache is a static asynchronous method that generates the latest QR code from cache. + * + * @param {string} orderRef - The order reference to be used for generating QR code. + * @param {QrGeneratorCache} [customCache=defaultCache] - Optional parameter, the cache store to be used for generating QR code. + * If no customCache is provided, the defaultCache is used. + * + * @returns {Promise} - It returns a Promise that resolves with the latest QR code for the provided order reference. + **/ + static async latestQrFromCache( + orderRef: string, + customCache: QrGeneratorCache = defaultCache, + ) { + const instance = new QrGenerator(null, { customCache }); + return (await instance.nextQr(orderRef, { maxCycles: 1 }).next()).value; + } + + /** + * Generator yielding a new value for the qrcode within + * the specified limits. + * @example + * ``` + * for await (const qr of qrInstance?.nextQr(orderRef, { timeout: 60 })) { + * // Put value from qr in a cache + * await sleep(2000) + * } + * ``` + **/ + async *nextQr( + orderRef: string, + { maxCycles, timeout }: QRGenerateOptions = { timeout: TIMEOUT }, + ) { + const qr = await this.cache.get(orderRef); + if (!qr) return; + for (let i = 0; i >= 0; i++) { + const secondsSinceStart = Math.floor((Date.now() - qr.startTime) / 1000); + + // Stop cycle if maxCycles is reached or timeout has occurred + if (maxCycles && i >= maxCycles) return; + if (timeout && timeout < secondsSinceStart) return; + + yield this.#generateQr( + qr.qrStartSecret, + qr.qrStartToken, + secondsSinceStart, + ); + } + } + + /** + * Private method `#generateQr` generates a new QR code + */ + #generateQr = (qrStartSecret: string, qrStartToken: string, time: number) => { + const qrAuthCode = createHmac("sha256", qrStartSecret) + .update(`${time}`) + .digest("hex"); + return `bankid.${qrStartToken}.${time}.${qrAuthCode}`; + }; +} diff --git a/tsconfig.json b/tsconfig.json index 33ea070..3e9b44f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,7 @@ { + "watchOptions": { + "excludeDirectories": ["**/node_modules", "/lib"] + }, "compilerOptions": { "declaration": true, "lib": ["ESNext"],