@ism/verify-sdk 1.0.116 → 1.1.136
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 +26 -5
- package/index.d.ts +9 -1
- package/index.js +26 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,10 +30,12 @@ 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
|
-
Sandbox clients
|
|
36
|
+
Sandbox clients use the same API and the same app. One difference: for production clients the
|
|
37
|
+
server re-checks the chip itself and its verdict decides (`verifiedBy: 'server'`); in sandbox that
|
|
38
|
+
check is only recorded.
|
|
37
39
|
|
|
38
40
|
## 1 · Start a verification
|
|
39
41
|
|
|
@@ -76,7 +78,25 @@ await ism.start({ claims: ['subject_is:EC-ID-0000000000'], level: 'L4', purpose:
|
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
A link is valid for a few minutes before the person first opens it; `expiresAt` says until when.
|
|
79
|
-
|
|
81
|
+
|
|
82
|
+
### Retrying safely
|
|
83
|
+
|
|
84
|
+
Each `start()` call starts a new verification. To make a retry safe — after a timeout, or from a
|
|
85
|
+
job runner that may run twice — pass an `idempotencyKey`, such as your own record's id:
|
|
86
|
+
|
|
87
|
+
```js
|
|
88
|
+
const { req, verifyUrl, replayed } = await ism.start({
|
|
89
|
+
bundle: 'bn_…',
|
|
90
|
+
redirectUri,
|
|
91
|
+
state,
|
|
92
|
+
idempotencyKey: `onboarding-${application.id}`,
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The same key with the same request, while its link is still valid, returns the verification the
|
|
97
|
+
first call started (`replayed: true`) instead of a second one. Once the link has expired the key
|
|
98
|
+
starts a new one. Reusing a key for a *different* request fails with `idempotency_conflict`. Keys
|
|
99
|
+
are scoped to your client and stored only as a hash.
|
|
80
100
|
|
|
81
101
|
## 2 · Collect the result
|
|
82
102
|
|
|
@@ -147,20 +167,21 @@ Every failed call throws a `VerifyError` with `status` (HTTP, or 0 when nothing
|
|
|
147
167
|
| `invalid_client` | the client id or secret was refused |
|
|
148
168
|
| `not_entitled` | the request asks for more than your client may — a claim, a level, a redirect URI. The message says which |
|
|
149
169
|
| `invalid_request` | a malformed request; on `/token`, also a code or reference that is unknown, expired or already released — deliberately indistinguishable |
|
|
170
|
+
| `idempotency_conflict` | the `idempotencyKey` was already used for a different request |
|
|
150
171
|
| `client_suspended` | the client is not active |
|
|
151
172
|
| `rate_limited` | slow down |
|
|
152
173
|
| `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
174
|
| `invalid_signature` | `verifyWebhook` refused the body |
|
|
154
175
|
| `state_mismatch` | `handleReturn` was given a URL whose `state` is not yours |
|
|
155
176
|
|
|
156
|
-
No call is retried by the SDK.
|
|
177
|
+
No call is retried by the SDK; `idempotencyKey` is what makes your own retry of `start()` safe.
|
|
157
178
|
|
|
158
179
|
## Reference
|
|
159
180
|
|
|
160
181
|
| | |
|
|
161
182
|
|---|---|
|
|
162
183
|
| `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 }` |
|
|
184
|
+
| `start({ bundle \| claims, level, purpose, retention?, claimLabels?, redirectUri, state?, network?, endUserIp?, idempotencyKey? })` | → `{ req, verifyUrl, expiresAt, replayed }` |
|
|
164
185
|
| `exchange(code)` | → result |
|
|
165
186
|
| `fetchResult(req)` | → result |
|
|
166
187
|
| `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.136",
|
|
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": "6331d1db35381fbcd40ec873c9720d5d269ad95f"
|
|
34
34
|
}
|