@ism/verify-sdk 1.0.116 → 1.1.118
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/README.md +23 -4
- package/index.d.ts +9 -1
- package/index.js +26 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ npm install @ism/verify-sdk
|
|
|
30
30
|
| `clientId` | `cl_…` |
|
|
31
31
|
| `clientSecret` | `sk_…` — shown once, when the client is created. Keep it in your secret store |
|
|
32
32
|
| A bundle | `bn_…` — what you ask for, at which assurance level, and the purpose the person reads |
|
|
33
|
-
| A redirect URI | where the person returns after the app. It must match one registered on your client exactly |
|
|
33
|
+
| A redirect URI | where the person returns after the app. It must match one registered on your client exactly. Every new client starts with `https://id.ism.ec/listo`, a page that tells the person they are done, so you can start before your own page exists |
|
|
34
34
|
| A webhook (recommended) | so you learn the outcome even when the person does not come back |
|
|
35
35
|
|
|
36
36
|
Sandbox clients behave exactly like production ones, with the same API and the same app.
|
|
@@ -76,7 +76,25 @@ await ism.start({ claims: ['subject_is:EC-ID-0000000000'], level: 'L4', purpose:
|
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
A link is valid for a few minutes before the person first opens it; `expiresAt` says until when.
|
|
79
|
-
|
|
79
|
+
|
|
80
|
+
### Retrying safely
|
|
81
|
+
|
|
82
|
+
Each `start()` call starts a new verification. To make a retry safe — after a timeout, or from a
|
|
83
|
+
job runner that may run twice — pass an `idempotencyKey`, such as your own record's id:
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
const { req, verifyUrl, replayed } = await ism.start({
|
|
87
|
+
bundle: 'bn_…',
|
|
88
|
+
redirectUri,
|
|
89
|
+
state,
|
|
90
|
+
idempotencyKey: `onboarding-${application.id}`,
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The same key with the same request, while its link is still valid, returns the verification the
|
|
95
|
+
first call started (`replayed: true`) instead of a second one. Once the link has expired the key
|
|
96
|
+
starts a new one. Reusing a key for a *different* request fails with `idempotency_conflict`. Keys
|
|
97
|
+
are scoped to your client and stored only as a hash.
|
|
80
98
|
|
|
81
99
|
## 2 · Collect the result
|
|
82
100
|
|
|
@@ -147,20 +165,21 @@ Every failed call throws a `VerifyError` with `status` (HTTP, or 0 when nothing
|
|
|
147
165
|
| `invalid_client` | the client id or secret was refused |
|
|
148
166
|
| `not_entitled` | the request asks for more than your client may — a claim, a level, a redirect URI. The message says which |
|
|
149
167
|
| `invalid_request` | a malformed request; on `/token`, also a code or reference that is unknown, expired or already released — deliberately indistinguishable |
|
|
168
|
+
| `idempotency_conflict` | the `idempotencyKey` was already used for a different request |
|
|
150
169
|
| `client_suspended` | the client is not active |
|
|
151
170
|
| `rate_limited` | slow down |
|
|
152
171
|
| `timeout`, `network_error` | no answer arrived. **Do not retry `exchange` or `fetchResult` blindly**: the result may already have been released to the lost response |
|
|
153
172
|
| `invalid_signature` | `verifyWebhook` refused the body |
|
|
154
173
|
| `state_mismatch` | `handleReturn` was given a URL whose `state` is not yours |
|
|
155
174
|
|
|
156
|
-
No call is retried by the SDK.
|
|
175
|
+
No call is retried by the SDK; `idempotencyKey` is what makes your own retry of `start()` safe.
|
|
157
176
|
|
|
158
177
|
## Reference
|
|
159
178
|
|
|
160
179
|
| | |
|
|
161
180
|
|---|---|
|
|
162
181
|
| `new Verify({ clientId, clientSecret, baseUrl?, timeoutMs?, fetch? })` | `baseUrl` defaults to `https://api.ism.ec/verify/v1`; `timeoutMs` to 10 000 |
|
|
163
|
-
| `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp? })` | → `{ req, verifyUrl, expiresAt }` |
|
|
182
|
+
| `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp?, idempotencyKey? })` | → `{ req, verifyUrl, expiresAt, replayed }` |
|
|
164
183
|
| `exchange(code)` | → result |
|
|
165
184
|
| `fetchResult(req)` | → result |
|
|
166
185
|
| `handleReturn(url, { expectedState? })` | → `{ ok: true, result }` or `{ ok: false, error, outcome, state }` |
|
package/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type Outcome = 'completed' | 'denied' | 'failed' | 'abandoned';
|
|
|
6
6
|
export declare class VerifyError extends Error {
|
|
7
7
|
/** HTTP status, or 0 when no response arrived. */
|
|
8
8
|
readonly status: number;
|
|
9
|
-
/** invalid_client, invalid_request, not_entitled, client_suspended, rate_limited, timeout, network_error, invalid_signature, state_mismatch, … */
|
|
9
|
+
/** invalid_client, invalid_request, not_entitled, idempotency_conflict, client_suspended, rate_limited, timeout, network_error, invalid_signature, state_mismatch, … */
|
|
10
10
|
readonly code: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -29,6 +29,12 @@ interface StartCommon {
|
|
|
29
29
|
network?: boolean;
|
|
30
30
|
/** The person's IP as your backend saw it, for network signals. */
|
|
31
31
|
endUserIp?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Makes a retried start safe: the same key with the same request, while its link is still
|
|
34
|
+
* valid, returns the first verification instead of starting a second. Use your own record's
|
|
35
|
+
* id. 8–255 printable ASCII characters, no spaces.
|
|
36
|
+
*/
|
|
37
|
+
idempotencyKey?: string;
|
|
32
38
|
/** Where the document evidence comes from. Default: the document's chip. */
|
|
33
39
|
evidence?: { source: 'chip' };
|
|
34
40
|
}
|
|
@@ -54,6 +60,8 @@ export interface Started {
|
|
|
54
60
|
/** Send the person here. */
|
|
55
61
|
verifyUrl: string;
|
|
56
62
|
expiresAt: string;
|
|
63
|
+
/** true when an idempotencyKey matched an earlier start and this is that verification. */
|
|
64
|
+
replayed: boolean;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
export interface VerificationResult {
|
package/index.js
CHANGED
|
@@ -8,15 +8,17 @@
|
|
|
8
8
|
// - The client secret travels only in the Authorization header of `/par` and `/token`, never in
|
|
9
9
|
// a body, a URL, an error or a log line. It lives in a private field, so printing the client
|
|
10
10
|
// does not print it.
|
|
11
|
-
// - No call is retried. `/par` mints a new verification on every call
|
|
12
|
-
//
|
|
11
|
+
// - No call is retried by the SDK. `/par` mints a new verification on every call unless the
|
|
12
|
+
// caller passes `idempotencyKey`, which makes the caller's own retry safe; `/token` releases a
|
|
13
|
+
// result exactly once, so a retry after a lost response would find it already released.
|
|
13
14
|
// - This file logs nothing. Claims are personal data, and logging is the caller's decision.
|
|
14
15
|
|
|
15
16
|
const crypto = require('node:crypto');
|
|
16
17
|
|
|
17
18
|
const DEFAULT_BASE_URL = 'https://api.ism.ec/verify/v1';
|
|
18
19
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
19
|
-
const
|
|
20
|
+
const LEVEL = /^L[0-9]$/;
|
|
21
|
+
const IDEMPOTENCY_KEY = /^[\x21-\x7e]{8,255}$/;
|
|
20
22
|
const EVIDENCE_SOURCES = ['chip'];
|
|
21
23
|
|
|
22
24
|
class VerifyError extends Error {
|
|
@@ -93,7 +95,7 @@ class Verify {
|
|
|
93
95
|
toJSON() { return { clientId: this.#clientId, baseUrl: this.#baseUrl }; }
|
|
94
96
|
[Symbol.for('nodejs.util.inspect.custom')]() { return `Verify { clientId: '${this.#clientId}', baseUrl: '${this.#baseUrl}' }`; }
|
|
95
97
|
|
|
96
|
-
async #post(path, body) {
|
|
98
|
+
async #post(path, body, { withHeaders = false } = {}) {
|
|
97
99
|
let response;
|
|
98
100
|
try {
|
|
99
101
|
response = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
@@ -124,21 +126,23 @@ class Verify {
|
|
|
124
126
|
if (!parsed || typeof parsed !== 'object') {
|
|
125
127
|
throw new VerifyError(`${path} answered with a body that is not JSON.`, { status: response.status, code: 'invalid_response' });
|
|
126
128
|
}
|
|
127
|
-
return parsed;
|
|
129
|
+
return withHeaders ? { body: parsed, headers: response.headers } : parsed;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
/**
|
|
131
133
|
* Starts a verification. Send the person to `verifyUrl`; keep `req` beside your own record.
|
|
132
134
|
*
|
|
133
135
|
* Name a bundle configured in the console (`bundle`), or compose the request here (`claims`,
|
|
134
|
-
* `level`, `purpose`). Each call starts a new verification
|
|
136
|
+
* `level`, `purpose`). Each call starts a new verification — unless it carries an
|
|
137
|
+
* `idempotencyKey` already used for the same request while its link is still valid, which
|
|
138
|
+
* answers with that first verification instead (`replayed: true`).
|
|
135
139
|
*
|
|
136
|
-
* @returns {Promise<{ req: string, verifyUrl: string, expiresAt: string }>}
|
|
140
|
+
* @returns {Promise<{ req: string, verifyUrl: string, expiresAt: string, replayed: boolean }>}
|
|
137
141
|
*/
|
|
138
142
|
async start(options = {}) {
|
|
139
143
|
const {
|
|
140
144
|
bundle, claims, level, purpose, retention, claimLabels,
|
|
141
|
-
redirectUri, state, network, endUserIp, evidence
|
|
145
|
+
redirectUri, state, network, endUserIp, evidence, idempotencyKey
|
|
142
146
|
} = options;
|
|
143
147
|
|
|
144
148
|
const source = evidence === undefined ? 'chip' : evidence && evidence.source;
|
|
@@ -159,7 +163,7 @@ class Verify {
|
|
|
159
163
|
if (!Array.isArray(claims) || claims.length === 0 || !claims.every((c) => typeof c === 'string' && c)) {
|
|
160
164
|
throw new TypeError('claims must be a non-empty array of claim names, or pass bundle.');
|
|
161
165
|
}
|
|
162
|
-
if (!
|
|
166
|
+
if (typeof level !== 'string' || !LEVEL.test(level)) throw new TypeError('level must be an assurance level such as L1 or L4.');
|
|
163
167
|
requireString(purpose, 'purpose');
|
|
164
168
|
body.claims = claims;
|
|
165
169
|
body.level = level;
|
|
@@ -173,12 +177,23 @@ class Verify {
|
|
|
173
177
|
body.network = network;
|
|
174
178
|
}
|
|
175
179
|
if (endUserIp !== undefined && endUserIp !== null) body.end_user_ip = requireString(endUserIp, 'endUserIp');
|
|
180
|
+
if (idempotencyKey !== undefined && idempotencyKey !== null) {
|
|
181
|
+
if (typeof idempotencyKey !== 'string' || !IDEMPOTENCY_KEY.test(idempotencyKey)) {
|
|
182
|
+
throw new TypeError('idempotencyKey must be 8–255 printable ASCII characters, with no spaces.');
|
|
183
|
+
}
|
|
184
|
+
body.idempotency_key = idempotencyKey;
|
|
185
|
+
}
|
|
176
186
|
|
|
177
|
-
const answer = await this.#post('/par', body);
|
|
187
|
+
const { body: answer, headers } = await this.#post('/par', body, { withHeaders: true });
|
|
178
188
|
if (typeof answer.req !== 'string' || typeof answer.verify_url !== 'string') {
|
|
179
189
|
throw new VerifyError('/par answered without req and verify_url.', { status: 200, code: 'invalid_response' });
|
|
180
190
|
}
|
|
181
|
-
return {
|
|
191
|
+
return {
|
|
192
|
+
req: answer.req,
|
|
193
|
+
verifyUrl: answer.verify_url,
|
|
194
|
+
expiresAt: answer.expires_at,
|
|
195
|
+
replayed: Boolean(headers && typeof headers.get === 'function' && headers.get('idempotent-replayed') === 'true')
|
|
196
|
+
};
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ism/verify-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.118",
|
|
4
4
|
"description": "Verify with ISM for relying-party backends: start a verification, collect the result, check webhook signatures.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -30,5 +30,5 @@
|
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "44d2fc4511c4e4fd3a685616fc74ed2bafa0b69f"
|
|
34
34
|
}
|