autogram-sdk
Version:
SDK for Autogram signer
360 lines (326 loc) • 11.5 kB
text/typescript
import fetch from "cross-fetch";
import { getRandomBytes, toHex, toUint32 } from "./crypto/random";
import { components } from "./autogram-api.generated";
/**
* Octosign White Label API client for the app running in the server mode.
*
* ### Example (es module and async/await)
* ```js
* import { apiClient } from '@octosign/client';
* const client = apiClient();
*
* // Launch URL that should be used by user to launch the signer application
* console.log(client.getLaunchURL());
*
* await client.waitForStatus('READY');
*
* const content = '<?xml version="1.0"?><Document><Title>Lorem Ipsum</Title></Document>';
* console.log(await client.sign({ content }));
* // => { content: '<?xml version="1.0"?><Document><Title>Lorem Ipsum</Title>...</Document>' }
* ```
*
* All further examples use es module and async/await, but the library can be also used as commonjs module and using promises.
*
* ### Example (commonjs and promises)
* ```js
* var apiClient = require('@octosign/client').apiClient;
* var client = apiClient();
*
* console.log(client.getLaunchURL());
*
* client.waitForStatus('READY')
* .then(function() {
* var content = '<?xml version="1.0"?><Document><Title>Lorem Ipsum</Title></Document>';
* return client.sign({ content: content });
* })
* .then(function(signedDocument) {
* console.log(signedDocument);
* // => { content: '<?xml version="1.0"?><Document><Title>Lorem Ipsum</Title>...signature...</Document>' }
* });
* ```
*
* @returns An instance of API client.
*/
export function apiClient(options?: ApiClientConfiguration) {
const configurationDefaults = {
serverProtocol: "http",
serverHost: "localhost",
serverPort: 37200,
customProtocol: "autogram",
disableSecurity: false,
requestsOrigin: typeof location !== "undefined" ? location.origin : "*",
secretKey: toHex(getRandomBytes(32)),
secretInitialNonce: toUint32(getRandomBytes(4)),
language: "sk",
} as const;
const configuration = { ...configurationDefaults, ...options };
// TODO: We should keep it as 32-bit int that can overflow back to minimum value
// TODO: There is one nonce for each sensitive point like sign
// const nonce = configurationDefaults.secretInitialNonce;
const serverUrl = new URL(
`${configuration.serverProtocol}://${configuration.serverHost}:${configuration.serverPort}`
);
return {
/**
* Construct custom protocol launch URI that can be opened by user to launch the application.
*
* ### Example
* ```js
* import { apiClient } from '@octosign/client';
* const client = apiClient();
* console.log(client.createLaunchURI());
* // => autogram://listen/37200/https%3A%2F%2Fexample.com/3a2bca8d73c62e75177fa877de283cc0c96cdf3ba08f8eb878a96da93de3d798/260372071
* ```
*
* @returns URL that can be opened by the user.
*/
getLaunchURL(command: "listen" = "listen") {
const params = new URLSearchParams();
params.set("protocol", configuration.serverProtocol);
params.set("port", configuration.serverPort.toString());
params.set("host", configuration.serverHost);
params.set("origin", configuration.requestsOrigin);
if (configuration.language) {
params.set("language", configuration.language);
}
if (!configuration.disableSecurity) {
if (configuration.secretKey) {
params.set("key", configuration.secretKey);
}
if (configuration.secretInitialNonce) {
params.set("nonce", configuration.secretInitialNonce.toString());
}
}
return `${configuration.customProtocol}://${command}?${params}`;
},
/**
* Retrieve server info with its current state.
*
* ### Example
* ```js
* import { apiClient } from '@octosign/client';
* const client = apiClient();
* console.log(await client.info());
* // => { version: '1.2.3', status: 'READY' }
* ```
*
* @returns Info about the server and its current state.
*/
info(): Promise<ServerInfo> {
const url = new URL("info", serverUrl);
const init = { cache: "no-store" } as const;
return fetch(url.toString(), init).then((response) => response.json());
},
/**
* Wait for server to be in the requested state.
*
* Repeatedly tries to get server info retrying
*
* ### Example
* ```js
* import { apiClient } from '@octosign/client';
* const client = apiClient();
* await client.waitForStatus('READY');
* // => { version: '1.2.3', status: 'READY' }
* ```
*
* @param status - Wanted status of the server.
* @param timeout - Timeout in seconds before giving up and rejecting with error.
* @param delay - Delay before making next attempt after failure.
* @returns Info about the server and its current state.
*/
waitForStatus(
status: ServerInfo["status"],
timeout = 60,
delay = 4,
abortController?: AbortController
): Promise<ServerInfo> {
const url = new URL("info", serverUrl);
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
let requestAbortController: AbortController;
let lastResponse: ServerInfo;
let lastError: Error = new Error("No request ever finished");
let finished = false;
if (abortController) {
abortController.signal.addEventListener("abort", () => {
if (!requestAbortController.signal.aborted)
requestAbortController.abort();
finished = true;
reject(new Error("Aborted"));
});
}
const overallTimeout = setTimeout(() => {
if (!requestAbortController.signal.aborted)
requestAbortController.abort();
finished = true;
reject(lastError);
}, timeout * 1000);
// _eslint-disable-next-line functional/no-loop-statement
while (!finished) {
requestAbortController = new AbortController();
const requestTimeout = setTimeout(
() => {
if (!requestAbortController.signal.aborted)
requestAbortController.abort();
},
(delay + 1) * 1000
);
try {
lastResponse = await (
await fetch(url.toString(), {
cache: "no-store",
signal: requestAbortController.signal,
})
).json();
if (lastResponse.status === status) {
finished = true;
clearTimeout(overallTimeout);
clearTimeout(requestTimeout);
resolve(lastResponse);
break;
}
} catch (error) {
clearTimeout(requestTimeout);
if (error.name !== "AbortError") {
lastError = error;
}
}
await new Promise((resolve) => setTimeout(resolve, delay * 1000));
}
});
},
/**
* Sign a document.
*
* ### Example
* ```js
* import { apiClient } from '@octosign/client';
* const client = apiClient();
*
* console.log(await client.sign({ content: '<?xml version="1.0"?><Document><Title>Lorem Ipsum</Title></Document>' }));
* // => { content: '...signed document...' }
* ```
*
* @param signatureParameters - Optional signature parameters.
* @param payloadMimeType - Optional payload mime type, defaults to `'application/xml'` - plaintext XML. Must reflect document content type so should be changed if content is not a plaintext XML.
* @returns Signed document.
*/
sign(
document: AutogramDocument,
signatureParameters: SignatureParameters = {
level: "XAdES_BASELINE_B",
checkPDFACompliance: true,
},
payloadMimeType = "application/xml"
): Promise<SignResponseBody> {
const url = new URL("sign", serverUrl);
const body: AutogramSignRequestBody = {
document,
parameters: signatureParameters,
payloadMimeType,
};
const init: RequestInit = {
method: "POST",
// Server considers text/plain as JSON to prevent CORS preflight
headers: { "Content-Type": "text/plain" },
cache: "no-store",
body: JSON.stringify(body),
} as const;
return fetch(url.toString(), init).then((response) => {
if (response.status == 204) {
throw new UserCancelledSigningException();
}
return response.json();
});
},
};
}
/**
* Client configuration options.
*/
export type ApiClientConfiguration = {
/**
* Protocol of the server - Octosign White Label
*
* Defaults to `'http'`
*/
readonly serverProtocol?: "http" | "https";
/**
* Host of the server - Octosign White Label
*
* Defaults to `'127.0.0.1'`
*/
readonly serverHost?: string;
/**
* Port of the server - Octosign White Label
*
* Influences also the URL generated to launch the application.
*
* Defaults to `37200`
*/
readonly serverPort?: number;
/**
* Custom protocol used with the application, e.g., to launch it - `signer://listen`.
*
* Defaults to `'signer'`.
*/
readonly customProtocol?: string;
/**
* Disables using of HMAC in messages and sets `requestsOrigin` to *
*
* Make sure you understand the implications on the security!
*
* Defaults to `false`.
*/
readonly disableSecurity?: boolean;
/**
* Origin the server will be asked to trust
*
* Influences generated launch URL that sets trusted origin
*
* Defaults to the current location origin, e.g. `'https://example.com'` if available, `'*'` otherwise.
*/
readonly requestsOrigin?: string;
/**
* Secret key used for the communication.
*
* Should be cryptographically secure random hex string generated separately for each session.
* You can generate this key on the server side and set it on the session so its reused across navigation.
* There is no need to configure this unless you know what you are doing.
*
* Defaults to random 64-char (256-bit) hex string generated on instantiation.
*/
readonly secretKey?: string;
/**
* Secret initial nonce used for the communication.
*
* Should be cryptographically secure random 32-bit integer generated separately for each session.
* You can generate this number on the server side and set it on the session so its reused across navigation.
* There is no need to configure this unless you know what you are doing.
*
* Defaults to random integer generated on instantiation.
*/
readonly secretInitialNonce?: number;
/**
*
* Language of interface used
*
*/
readonly language?: "sk";
};
/**
* Info about the server and its current state.
*/
export type ServerInfo = components["schemas"]["Info"];
/**
* Document exchanged during the signing.
*/
export type AutogramDocument = components["schemas"]["Document"];
/**
* Parameters used to create a signature.
*/
export type SignatureParameters = components["schemas"]["SignatureParameters"];
type AutogramSignRequestBody = components["schemas"]["SignRequestBody"];
export type SignResponseBody = components["schemas"]["SignResponseBody"];
export class UserCancelledSigningException {}