attestid_js_sdk 0.1.11 → 0.1.13
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 +22 -0
- package/dist/core/index.cjs +166 -31
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +50 -1
- package/dist/core/index.d.ts +50 -1
- package/dist/core/index.js +165 -31
- package/dist/core/index.js.map +1 -1
- package/dist/react/index.cjs +62 -62
- package/dist/react/index.d.cts +31 -0
- package/dist/react/index.d.ts +31 -0
- package/dist/react/index.js +62 -62
- package/dist/styles/attestid.css +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -107,6 +107,28 @@ States: `idle`, `quality_check`, `uploading`, `retrying`, `liveness`, `completin
|
|
|
107
107
|
|
|
108
108
|
Supported `idType` values: `national_id`, `passport`, `drivers_licence`.
|
|
109
109
|
|
|
110
|
+
### Back of the card (optional)
|
|
111
|
+
|
|
112
|
+
Many ID cards and driver's licences print their machine-readable zone (MRZ, the lines of `<<<`) on the back. For `national_id` and `drivers_licence` you can also send a photo of the back. The API cross-checks the MRZ's date of birth and expiry against the front, and rejects a mismatch as `document_sides_mismatch`. A back with no MRZ adds nothing, so check it first and only send it when an MRZ was found:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { checkBackPhotoQuality } from 'attestid_js_sdk';
|
|
116
|
+
|
|
117
|
+
const back = await checkBackPhotoQuality(backPhoto);
|
|
118
|
+
startVerification({
|
|
119
|
+
userId, idType: 'national_id', idPhoto, consentGivenAt,
|
|
120
|
+
idPhotoBack: back.hasMrz ? backPhoto : undefined,
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Whether to ask for the back is each company's choice, set on the AttestID dashboard under Settings: `off` (the default; a back sent anyway is ignored), `optional` (ask, but the user can skip it) or `required` (the back must be sent with a readable MRZ). Read it with `client.getCaptureSettings(userId)`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const { document_back } = await client.getCaptureSettings(userId); // 'off' | 'optional' | 'required'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`AttestIDVerifier` handles all of this for you. It reads the setting, then after the front it asks for the back when the policy is `optional` (with a skip button) or `required` (no skip).
|
|
131
|
+
|
|
110
132
|
## Verify results on your backend
|
|
111
133
|
|
|
112
134
|
Always verify `result.attestation` on your backend before acting on a result.
|
package/dist/core/index.cjs
CHANGED
|
@@ -40,6 +40,7 @@ __export(core_exports, {
|
|
|
40
40
|
buildHandoffUrl: () => buildHandoffUrl,
|
|
41
41
|
canRetryLiveness: () => canRetryLiveness,
|
|
42
42
|
canonicalJson: () => canonicalJson,
|
|
43
|
+
checkBackPhotoQuality: () => checkBackPhotoQuality,
|
|
43
44
|
checkCaptureConditions: () => checkCaptureConditions,
|
|
44
45
|
checkPhotoQuality: () => checkPhotoQuality,
|
|
45
46
|
classifyConditions: () => classifyConditions,
|
|
@@ -167,6 +168,8 @@ function randomId() {
|
|
|
167
168
|
// src/core/http.ts
|
|
168
169
|
var RETRY_FRESH_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
169
170
|
var MAX_RETRY_AFTER_MS = 1e4;
|
|
171
|
+
var DEFAULT_IN_PROGRESS_WAIT_MS = 12e4;
|
|
172
|
+
var IN_PROGRESS_ERROR = "idempotency_request_in_progress";
|
|
170
173
|
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
174
|
var sleep = defaultSleep;
|
|
172
175
|
function retryAfterMs(res, fallback) {
|
|
@@ -174,9 +177,19 @@ function retryAfterMs(res, fallback) {
|
|
|
174
177
|
const secs = raw ? Number(raw) : NaN;
|
|
175
178
|
return Number.isFinite(secs) && secs >= 0 ? Math.min(secs * 1e3, MAX_RETRY_AFTER_MS) : fallback;
|
|
176
179
|
}
|
|
180
|
+
async function isInProgress(res) {
|
|
181
|
+
try {
|
|
182
|
+
const body = await (res.clone ? res.clone() : res).json();
|
|
183
|
+
return (body?.detail?.error ?? body?.error) === IN_PROGRESS_ERROR;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
177
188
|
async function postIdempotent(fetchImpl, url, build, options = {}) {
|
|
178
189
|
const attempts = Math.max(1, options.attempts ?? 3);
|
|
179
190
|
const base = options.baseDelayMs ?? 500;
|
|
191
|
+
const inProgressBudget = options.inProgressWaitMs ?? DEFAULT_IN_PROGRESS_WAIT_MS;
|
|
192
|
+
let inProgressWaited = 0;
|
|
180
193
|
let key = randomId();
|
|
181
194
|
let req = await build();
|
|
182
195
|
for (let attempt = 1; ; attempt++) {
|
|
@@ -193,11 +206,17 @@ async function postIdempotent(fetchImpl, url, build, options = {}) {
|
|
|
193
206
|
await sleep(base * 2 ** (attempt - 1));
|
|
194
207
|
continue;
|
|
195
208
|
}
|
|
196
|
-
if (
|
|
209
|
+
if (res.ok) return res;
|
|
197
210
|
if (res.status === 409) {
|
|
198
|
-
await
|
|
211
|
+
if (!await isInProgress(res)) return res;
|
|
212
|
+
const delay = retryAfterMs(res, base);
|
|
213
|
+
if (inProgressWaited + delay > inProgressBudget) return res;
|
|
214
|
+
inProgressWaited += delay;
|
|
215
|
+
await sleep(delay);
|
|
216
|
+
attempt--;
|
|
199
217
|
continue;
|
|
200
218
|
}
|
|
219
|
+
if (last) return res;
|
|
201
220
|
if (RETRY_FRESH_STATUSES.has(res.status)) {
|
|
202
221
|
await sleep(retryAfterMs(res, base * 2 ** (attempt - 1)));
|
|
203
222
|
key = randomId();
|
|
@@ -254,6 +273,8 @@ function countTextRows(grey, w, h, x0, aw) {
|
|
|
254
273
|
return textRows;
|
|
255
274
|
}
|
|
256
275
|
var EDGE_DELTA = 28;
|
|
276
|
+
var EDGE_DELTA_MIN = 12;
|
|
277
|
+
var EDGE_DELTA_P99_FRACTION = 0.8;
|
|
257
278
|
var NOISY_MEDIAN_STEP = 9;
|
|
258
279
|
var SKEW_TANS = [0, 0.0175, -0.0175, 0.0349, -0.0349, 0.0524, -0.0524, 0.0699, -0.0699];
|
|
259
280
|
var STRIPS = 8;
|
|
@@ -269,14 +290,15 @@ function idTextNotFoundMessage(idType) {
|
|
|
269
290
|
const doc = idType === "drivers_licence" ? "driver's licence" : "ID card";
|
|
270
291
|
return `We could not detect readable printed details on your ${doc}. Photograph the front of the card (the side with your photo, name and ID number) so it fills the frame, in good light and without glare.`;
|
|
271
292
|
}
|
|
272
|
-
function measureTextBands(grey, w, h, x0 = 0, aw = w) {
|
|
293
|
+
function measureTextBands(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
|
|
273
294
|
const src = medianStep(grey, w, h, x0, aw) >= NOISY_MEDIAN_STEP ? boxSmooth(grey, w, h, x0, aw) : null;
|
|
274
295
|
const at = src ? (y, x) => src[y * aw + x] : (y, x) => grey[y * w + x0 + x];
|
|
296
|
+
const edgeDelta = edgeDeltaOverride ?? edgeThreshold(at, aw, h);
|
|
275
297
|
const ex = [];
|
|
276
298
|
const ey = [];
|
|
277
299
|
for (let y = 0; y < h; y++) {
|
|
278
300
|
for (let x = 0; x < aw - 1; x++) {
|
|
279
|
-
if (Math.abs(at(y, x + 1) - at(y, x)) >
|
|
301
|
+
if (Math.abs(at(y, x + 1) - at(y, x)) > edgeDelta) {
|
|
280
302
|
ex.push(x);
|
|
281
303
|
ey.push(y);
|
|
282
304
|
}
|
|
@@ -379,6 +401,7 @@ function measureTextBands(grey, w, h, x0 = 0, aw = w) {
|
|
|
379
401
|
height,
|
|
380
402
|
strip: s,
|
|
381
403
|
firstCell: first,
|
|
404
|
+
lastCell: last,
|
|
382
405
|
spanFrac: span / cps,
|
|
383
406
|
coverage: active / span,
|
|
384
407
|
density: inside / (span * CELL * height),
|
|
@@ -414,6 +437,25 @@ function medianStep(grey, w, h, x0, aw) {
|
|
|
414
437
|
}
|
|
415
438
|
return 255;
|
|
416
439
|
}
|
|
440
|
+
function edgeThreshold(at, aw, h) {
|
|
441
|
+
const hist = new Uint32Array(256);
|
|
442
|
+
let n = 0;
|
|
443
|
+
for (let y = 0; y < h; y++) {
|
|
444
|
+
for (let x = 0; x < aw - 1; x++) {
|
|
445
|
+
hist[Math.min(255, Math.round(Math.abs(at(y, x + 1) - at(y, x))))]++;
|
|
446
|
+
n++;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
let acc = 0, p99 = 255;
|
|
450
|
+
for (let v = 0; v < 256; v++) {
|
|
451
|
+
acc += hist[v];
|
|
452
|
+
if (acc >= n * 0.99) {
|
|
453
|
+
p99 = v;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return Math.max(EDGE_DELTA_MIN, Math.min(EDGE_DELTA, Math.round(p99 * EDGE_DELTA_P99_FRACTION)));
|
|
458
|
+
}
|
|
417
459
|
function boxSmooth(grey, w, h, x0, aw) {
|
|
418
460
|
const out = new Float32Array(aw * h);
|
|
419
461
|
for (let y = 0; y < h; y++) {
|
|
@@ -459,15 +501,17 @@ function periodicity(profile, glyphHeight) {
|
|
|
459
501
|
}
|
|
460
502
|
return best;
|
|
461
503
|
}
|
|
462
|
-
function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
|
|
463
|
-
const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw);
|
|
504
|
+
function analyseTextLayout(grey, w, h, x0 = 0, aw = w, edgeDeltaOverride) {
|
|
505
|
+
const { bands, fullWidthLines } = measureTextBands(grey, w, h, x0, aw, edgeDeltaOverride);
|
|
464
506
|
const stripCells = Math.ceil(Math.max(1, Math.floor(aw / STRIPS)) / CELL);
|
|
465
507
|
const clean = bands.filter((b) => b.spanFrac >= 0.2 && b.coverage >= 0.35 && b.bleed <= TEXT_MAX_BLEED && b.spanFrac * stripCells * CELL / b.height >= 3);
|
|
466
508
|
const textLines = clusterBands(clean).length;
|
|
467
509
|
let alignedLines = 0;
|
|
468
510
|
for (const b of clean) {
|
|
469
|
-
const
|
|
470
|
-
|
|
511
|
+
const sameStrip = clean.filter((o) => o.strip === b.strip);
|
|
512
|
+
const left = sameStrip.filter((o) => Math.abs(o.firstCell - b.firstCell) <= 1).length;
|
|
513
|
+
const right = sameStrip.filter((o) => Math.abs(o.lastCell - b.lastCell) <= 1).length;
|
|
514
|
+
alignedLines = Math.max(alignedLines, left, right);
|
|
471
515
|
}
|
|
472
516
|
const isMrzLine = (l) => l.bleed <= MRZ_MAX_BLEED && l.periodicity >= MRZ_MIN_PERIODICITY;
|
|
473
517
|
const stacked = (p, q) => {
|
|
@@ -475,19 +519,23 @@ function analyseTextLayout(grey, w, h, x0 = 0, aw = w) {
|
|
|
475
519
|
const pitch = q.center - p.center;
|
|
476
520
|
return pitch >= 1.3 * avgH && pitch <= 2.8 * avgH && Math.max(p.height, q.height) / Math.min(p.height, q.height) <= 1.4;
|
|
477
521
|
};
|
|
478
|
-
let
|
|
522
|
+
let hasMrz2 = false;
|
|
479
523
|
let runStart = 0;
|
|
480
524
|
for (let i = 1; i <= fullWidthLines.length; i++) {
|
|
481
525
|
if (i < fullWidthLines.length && stacked(fullWidthLines[i - 1], fullWidthLines[i])) continue;
|
|
482
526
|
const run = fullWidthLines.slice(runStart, i);
|
|
483
527
|
if (run.length >= 2 && run.length <= 3) {
|
|
484
528
|
for (let j = 1; j < run.length; j++) {
|
|
485
|
-
if (isMrzLine(run[j - 1]) && isMrzLine(run[j]))
|
|
529
|
+
if (isMrzLine(run[j - 1]) && isMrzLine(run[j])) hasMrz2 = true;
|
|
486
530
|
}
|
|
487
531
|
}
|
|
488
532
|
runStart = i;
|
|
489
533
|
}
|
|
490
|
-
return { textLines, alignedLines, hasMrz };
|
|
534
|
+
return { textLines, alignedLines, hasMrz: hasMrz2 };
|
|
535
|
+
}
|
|
536
|
+
var MRZ_RETRY_EDGE_DELTA = 14;
|
|
537
|
+
function hasMrz(grey, w, h, x0 = 0, aw = w) {
|
|
538
|
+
return analyseTextLayout(grey, w, h, x0, aw).hasMrz || analyseTextLayout(grey, w, h, x0, aw, MRZ_RETRY_EDGE_DELTA).hasMrz;
|
|
491
539
|
}
|
|
492
540
|
async function detectText(source) {
|
|
493
541
|
const TextDetectorCtor = globalThis.TextDetector;
|
|
@@ -516,14 +564,14 @@ function laplacianVariance(grey, w, h, x0, aw) {
|
|
|
516
564
|
function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
|
|
517
565
|
const ratio = w / h;
|
|
518
566
|
const regions = ratio > 2 ? [{ x0: 0, aw: Math.floor(w / 2) }, { x0: Math.floor(w / 2), aw: w - Math.floor(w / 2) }] : [{ x0: 0, aw: w }];
|
|
519
|
-
let dataPage = regions.find(({ x0, aw }) =>
|
|
567
|
+
let dataPage = regions.find(({ x0, aw }) => hasMrz(grey, w, h, x0, aw));
|
|
520
568
|
if (!dataPage && browserFoundMrz) {
|
|
521
569
|
dataPage = regions.reduce((best, r) => countTextRows(grey, w, h, r.x0, r.aw) > countTextRows(grey, w, h, best.x0, best.aw) ? r : best);
|
|
522
570
|
}
|
|
523
571
|
if (!dataPage) return [MRZ_NOT_FOUND_MESSAGE];
|
|
524
572
|
if (laplacianVariance(grey, w, h, dataPage.x0, dataPage.aw) < BLUR_THRESHOLD) {
|
|
525
573
|
return [
|
|
526
|
-
"The data page is too blurry
|
|
574
|
+
"The data page is too blurry. Hold the camera steady and make sure the MRZ text at the bottom of the data page is in sharp focus."
|
|
527
575
|
];
|
|
528
576
|
}
|
|
529
577
|
return [];
|
|
@@ -531,6 +579,7 @@ function checkPassportDocument(grey, w, h, browserFoundMrz = false) {
|
|
|
531
579
|
function checkReadableText(grey, w, h, idType, browserTextBlocks = null) {
|
|
532
580
|
if ((browserTextBlocks ?? 0) >= 3) return [];
|
|
533
581
|
const layout = analyseTextLayout(grey, w, h);
|
|
582
|
+
if (layout.hasMrz) return [];
|
|
534
583
|
if (layout.textLines >= MIN_ID_TEXT_LINES && layout.alignedLines >= MIN_ID_ALIGNED_LINES) return [];
|
|
535
584
|
return [idTextNotFoundMessage(idType)];
|
|
536
585
|
}
|
|
@@ -539,7 +588,7 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
|
|
|
539
588
|
const totalTextRows = precomputedTotalTextRows ?? countTextRows(grey, w, h, 0, w);
|
|
540
589
|
if (totalTextRows < Math.max(2, h * 0.02)) {
|
|
541
590
|
return [
|
|
542
|
-
"This does not look like an identity document
|
|
591
|
+
"This does not look like an identity document: no printed text was detected. Please photograph your ID card or passport data page on a plain, well-lit background."
|
|
543
592
|
];
|
|
544
593
|
}
|
|
545
594
|
if (idType === "passport") {
|
|
@@ -555,14 +604,68 @@ function checkDocumentIdentity(grey, w, h, idType, precomputedTotalTextRows) {
|
|
|
555
604
|
}
|
|
556
605
|
return [];
|
|
557
606
|
}
|
|
607
|
+
var BACK_MRZ_NOT_FOUND_MESSAGE = "We could not read the machine-readable zone (MRZ) on the back of your card: the 3 lines of letters, numbers and <<< along the bottom. This usually means the photo is blurry or the card is too small in the frame. Fill the frame with the back of the card, hold the phone steady, and wait for the <<< lines to come into focus before capturing. Avoid glare on the card.";
|
|
608
|
+
function checkBackOfCard(grey, w, h, browserFoundMrz = false) {
|
|
609
|
+
let total = 0;
|
|
610
|
+
for (let i = 0; i < grey.length; i++) total += grey[i];
|
|
611
|
+
const brightness = total / grey.length;
|
|
612
|
+
const issues = [];
|
|
613
|
+
if (brightness < DARK_THRESHOLD) issues.push("Image is too dark. Move to a well-lit area.");
|
|
614
|
+
if (brightness > BRIGHT_THRESHOLD)
|
|
615
|
+
issues.push("Image is overexposed. Avoid direct light or glare shining onto the card.");
|
|
616
|
+
const mrzFound = browserFoundMrz || hasMrz(grey, w, h);
|
|
617
|
+
if (mrzFound && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD) {
|
|
618
|
+
issues.push("The back of the card is too blurry. Hold the camera steady so the lines of <<< are sharp.");
|
|
619
|
+
}
|
|
620
|
+
if (!mrzFound && issues.length === 0) issues.push(BACK_MRZ_NOT_FOUND_MESSAGE);
|
|
621
|
+
return { ok: issues.length === 0, issues, hasMrz: mrzFound };
|
|
622
|
+
}
|
|
623
|
+
async function checkBackPhotoQuality(photo) {
|
|
624
|
+
if (photo.size < MIN_FILE_BYTES) {
|
|
625
|
+
return { ok: false, hasMrz: false, issues: ["File is too small. This does not appear to be a real photo."] };
|
|
626
|
+
}
|
|
627
|
+
if (!await hasSupportedMagicBytes(photo)) {
|
|
628
|
+
return { ok: false, hasMrz: false, issues: ["Unsupported file type. Please upload a JPEG or PNG photo."] };
|
|
629
|
+
}
|
|
630
|
+
return new Promise((resolve) => {
|
|
631
|
+
const img = new Image();
|
|
632
|
+
const url = URL.createObjectURL(photo);
|
|
633
|
+
img.onerror = () => {
|
|
634
|
+
URL.revokeObjectURL(url);
|
|
635
|
+
resolve({ ok: false, hasMrz: false, issues: ["Could not read the image. Please try a different photo."] });
|
|
636
|
+
};
|
|
637
|
+
img.onload = async () => {
|
|
638
|
+
URL.revokeObjectURL(url);
|
|
639
|
+
const scale = Math.min(1, 640 / Math.max(img.width, img.height));
|
|
640
|
+
const w = Math.round(img.width * scale);
|
|
641
|
+
const h = Math.round(img.height * scale);
|
|
642
|
+
const canvas = document.createElement("canvas");
|
|
643
|
+
canvas.width = w;
|
|
644
|
+
canvas.height = h;
|
|
645
|
+
const ctx = canvas.getContext("2d");
|
|
646
|
+
ctx.imageSmoothingEnabled = false;
|
|
647
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
648
|
+
const textPromise = detectText(canvas);
|
|
649
|
+
const { data } = ctx.getImageData(0, 0, w, h);
|
|
650
|
+
const grey = new Float32Array(w * h);
|
|
651
|
+
for (let i = 0; i < grey.length; i++) {
|
|
652
|
+
grey[i] = 0.299 * data[i * 4] + 0.587 * data[i * 4 + 1] + 0.114 * data[i * 4 + 2];
|
|
653
|
+
}
|
|
654
|
+
const texts = await textPromise;
|
|
655
|
+
const browserFoundMrz = texts?.some((t) => looksLikeMrzLine(t.rawValue ?? "")) ?? false;
|
|
656
|
+
resolve(checkBackOfCard(grey, w, h, browserFoundMrz));
|
|
657
|
+
};
|
|
658
|
+
img.src = url;
|
|
659
|
+
});
|
|
660
|
+
}
|
|
558
661
|
async function checkPhotoQuality(photo, idType = "national_id") {
|
|
559
662
|
const issues = [];
|
|
560
663
|
if (photo.size < MIN_FILE_BYTES) {
|
|
561
|
-
issues.push("File is too small
|
|
664
|
+
issues.push("File is too small. This does not appear to be a real photo.");
|
|
562
665
|
return { ok: false, issues };
|
|
563
666
|
}
|
|
564
667
|
if (!await hasSupportedMagicBytes(photo)) {
|
|
565
|
-
issues.push("Unsupported file type
|
|
668
|
+
issues.push("Unsupported file type. Please upload a JPEG or PNG photo.");
|
|
566
669
|
return { ok: false, issues };
|
|
567
670
|
}
|
|
568
671
|
return new Promise((resolve) => {
|
|
@@ -570,7 +673,7 @@ async function checkPhotoQuality(photo, idType = "national_id") {
|
|
|
570
673
|
const url = URL.createObjectURL(photo);
|
|
571
674
|
img.onerror = () => {
|
|
572
675
|
URL.revokeObjectURL(url);
|
|
573
|
-
resolve({ ok: false, issues: ["Could not read the image
|
|
676
|
+
resolve({ ok: false, issues: ["Could not read the image. Please try a different photo."] });
|
|
574
677
|
};
|
|
575
678
|
img.onload = async () => {
|
|
576
679
|
URL.revokeObjectURL(url);
|
|
@@ -596,11 +699,11 @@ async function checkPhotoQuality(photo, idType = "national_id") {
|
|
|
596
699
|
}
|
|
597
700
|
const brightness = totalLum / len;
|
|
598
701
|
if (idType !== "passport" && laplacianVariance(grey, w, h, 0, w) < BLUR_THRESHOLD)
|
|
599
|
-
issues.push("Photo is too blurry
|
|
702
|
+
issues.push("Photo is too blurry. Hold the camera steady and make sure the document is in sharp focus.");
|
|
600
703
|
if (brightness < DARK_THRESHOLD)
|
|
601
|
-
issues.push("Image is too dark
|
|
704
|
+
issues.push("Image is too dark. Move to a well-lit area.");
|
|
602
705
|
if (brightness > BRIGHT_THRESHOLD)
|
|
603
|
-
issues.push("Image is overexposed
|
|
706
|
+
issues.push("Image is overexposed. Avoid direct light or glare shining onto the document.");
|
|
604
707
|
const totalTextRows = countTextRows(grey, w, h, 0, w);
|
|
605
708
|
const identityIssues = checkDocumentIdentity(grey, w, h, idType, totalTextRows);
|
|
606
709
|
issues.push(...identityIssues);
|
|
@@ -632,12 +735,14 @@ var fetchOf = (t) => t?.fetch ?? ((input, init) => fetch(input, init));
|
|
|
632
735
|
async function extractErrorMessage(res) {
|
|
633
736
|
const text = await res.text();
|
|
634
737
|
try {
|
|
635
|
-
const
|
|
738
|
+
const parsed = JSON.parse(text);
|
|
739
|
+
const detail = parsed.detail;
|
|
636
740
|
if (typeof detail === "string") return detail;
|
|
637
741
|
if (detail !== null && typeof detail === "object") {
|
|
638
742
|
const message = detail.message;
|
|
639
743
|
if (typeof message === "string") return message;
|
|
640
744
|
}
|
|
745
|
+
if (detail === void 0 && typeof parsed.message === "string") return parsed.message;
|
|
641
746
|
} catch {
|
|
642
747
|
}
|
|
643
748
|
return text;
|
|
@@ -672,13 +777,22 @@ function sdkHeaders(sdkToken, livenessFlows) {
|
|
|
672
777
|
}
|
|
673
778
|
async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, livenessFlows) {
|
|
674
779
|
if (!await hasSupportedMagicBytes(options.idPhoto)) {
|
|
675
|
-
throw new ValidationError("Unsupported image format
|
|
780
|
+
throw new ValidationError("Unsupported image format. Only JPEG and PNG photos are accepted.");
|
|
781
|
+
}
|
|
782
|
+
if (options.idPhotoBack) {
|
|
783
|
+
if (options.idType === "passport") {
|
|
784
|
+
throw new ValidationError("idPhotoBack is only accepted for national IDs and driver's licences.");
|
|
785
|
+
}
|
|
786
|
+
if (!await hasSupportedMagicBytes(options.idPhotoBack)) {
|
|
787
|
+
throw new ValidationError("Unsupported image format for the back of the card. Only JPEG and PNG photos are accepted.");
|
|
788
|
+
}
|
|
676
789
|
}
|
|
677
790
|
const build = async () => {
|
|
678
791
|
const form = new FormData();
|
|
679
792
|
form.append("user_id", options.userId);
|
|
680
793
|
form.append("id_type", options.idType);
|
|
681
794
|
form.append("id_photo", options.idPhoto);
|
|
795
|
+
if (options.idPhotoBack) form.append("id_photo_back", options.idPhotoBack);
|
|
682
796
|
form.append("consent_given_at", options.consentGivenAt.toISOString());
|
|
683
797
|
form.append("processing_purpose", options.processingPurpose ?? "identity_verification");
|
|
684
798
|
const signature = signCapture2 ? await signCapture2() : null;
|
|
@@ -690,6 +804,13 @@ async function startKYC(apiUrl, sdkToken, options, transport, signCapture2, live
|
|
|
690
804
|
const res = await postIdempotent(fetchOf(transport), `${apiUrl}/v1/kyc/start`, build, transport?.retry);
|
|
691
805
|
return handleResponse(res);
|
|
692
806
|
}
|
|
807
|
+
async function getCaptureSettings(apiUrl, sdkToken, transport) {
|
|
808
|
+
const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/capture-settings`, {
|
|
809
|
+
headers: { "Authorization": `Bearer ${sdkToken}` }
|
|
810
|
+
});
|
|
811
|
+
if (res.status === 404) return { document_back: "off" };
|
|
812
|
+
return handleResponse(res);
|
|
813
|
+
}
|
|
693
814
|
async function getLivenessCredentials(apiUrl, sdkToken, transport) {
|
|
694
815
|
const res = await fetchOf(transport)(`${apiUrl}/v1/kyc/liveness-credentials`, {
|
|
695
816
|
headers: { "Authorization": `Bearer ${sdkToken}` }
|
|
@@ -829,22 +950,26 @@ async function registerDeviceKey(fetchImpl, apiUrl, token, pair) {
|
|
|
829
950
|
});
|
|
830
951
|
return res.device_key_id;
|
|
831
952
|
}
|
|
832
|
-
function captureMessage(imageSha256Hex, userId, nonce, ts) {
|
|
953
|
+
function captureMessage(imageSha256Hex, userId, nonce, ts, backImageSha256Hex) {
|
|
954
|
+
if (backImageSha256Hex !== void 0) {
|
|
955
|
+
return `attestid-capture-v2|${imageSha256Hex}|${backImageSha256Hex}|${userId}|${nonce}|${ts}`;
|
|
956
|
+
}
|
|
833
957
|
return `attestid-capture-v1|${imageSha256Hex}|${userId}|${nonce}|${ts}`;
|
|
834
958
|
}
|
|
835
|
-
async function signCapture(fetchImpl, apiUrl, token, userId, photo) {
|
|
959
|
+
async function signCapture(fetchImpl, apiUrl, token, userId, photo, backPhoto) {
|
|
836
960
|
try {
|
|
837
961
|
if (!deviceBindingSupported()) return null;
|
|
838
962
|
const pair = await getDeviceKeyPair();
|
|
839
963
|
const deviceKeyId = await registerDeviceKey(fetchImpl, apiUrl, token, pair);
|
|
840
964
|
const imageHash = bytesToHex(await sha256(new Uint8Array(await photo.arrayBuffer())));
|
|
965
|
+
const backHash = backPhoto ? bytesToHex(await sha256(new Uint8Array(await backPhoto.arrayBuffer()))) : void 0;
|
|
841
966
|
const nonce = await challenge(fetchImpl, apiUrl, token);
|
|
842
967
|
const ts = Math.floor(Date.now() / 1e3);
|
|
843
968
|
const sig = new Uint8Array(
|
|
844
969
|
await subtle().sign(
|
|
845
970
|
{ name: "ECDSA", hash: "SHA-256" },
|
|
846
971
|
pair.privateKey,
|
|
847
|
-
buf(utf8(captureMessage(imageHash, userId, nonce, ts)))
|
|
972
|
+
buf(utf8(captureMessage(imageHash, userId, nonce, ts, backHash)))
|
|
848
973
|
)
|
|
849
974
|
);
|
|
850
975
|
return {
|
|
@@ -909,12 +1034,12 @@ function assertSecureApiUrl(apiUrl) {
|
|
|
909
1034
|
try {
|
|
910
1035
|
url = new URL(apiUrl);
|
|
911
1036
|
} catch {
|
|
912
|
-
throw new AttestIDError(`apiUrl must be a valid absolute URL
|
|
1037
|
+
throw new AttestIDError(`apiUrl must be a valid absolute URL, got "${apiUrl}".`, "INSECURE_CONFIG");
|
|
913
1038
|
}
|
|
914
1039
|
if (url.protocol === "https:") return;
|
|
915
1040
|
if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return;
|
|
916
1041
|
throw new AttestIDError(
|
|
917
|
-
`apiUrl must use https
|
|
1042
|
+
`apiUrl must use https://, got "${apiUrl}". Sending it over plain http exposes your API key, SDK token, and uploaded ID photos to anyone on the network path. http://localhost (or 127.0.0.1/::1) is allowed for local development against a backend running without TLS.`,
|
|
918
1043
|
"INSECURE_CONFIG"
|
|
919
1044
|
);
|
|
920
1045
|
}
|
|
@@ -931,7 +1056,7 @@ var AttestID = class {
|
|
|
931
1056
|
assertSecureApiUrl(config.apiUrl);
|
|
932
1057
|
if (isServerConfig(config) && !isTrustedServerEnvironment()) {
|
|
933
1058
|
throw new AttestIDError(
|
|
934
|
-
"apiKey/accessKeyId must only be used in a trusted Node.js backend process
|
|
1059
|
+
"apiKey/accessKeyId must only be used in a trusted Node.js backend process. A bundler would ship this long-lived secret to every client, letting anyone extract it. In browser or mobile app code, configure `getSdkToken` instead: a callback that fetches an already-minted sdk_token from your own backend, which holds apiKey server-side and calls POST /kyc/session. See the integration guide for the backend-proxy pattern.",
|
|
935
1060
|
"INSECURE_CONFIG"
|
|
936
1061
|
);
|
|
937
1062
|
}
|
|
@@ -960,7 +1085,7 @@ var AttestID = class {
|
|
|
960
1085
|
const minted = sessionPromise.then((session) => {
|
|
961
1086
|
if (!session?.sdk_token) {
|
|
962
1087
|
throw new AuthError(
|
|
963
|
-
"getSdkToken() did not return a valid sdk_token
|
|
1088
|
+
"getSdkToken() did not return a valid sdk_token. Check that your backend's /api/attestid-session endpoint (or equivalent) returned a successful response."
|
|
964
1089
|
);
|
|
965
1090
|
}
|
|
966
1091
|
this.tokenExpiresAt = Date.now() + session.expires_in * 1e3;
|
|
@@ -990,16 +1115,25 @@ var AttestID = class {
|
|
|
990
1115
|
async startVerification(options) {
|
|
991
1116
|
if (!options.consentGivenAt) {
|
|
992
1117
|
throw new AttestIDError(
|
|
993
|
-
"consentGivenAt is required
|
|
1118
|
+
"consentGivenAt is required: capture the timestamp of an explicit user consent action before starting verification",
|
|
994
1119
|
"CONSENT_REQUIRED"
|
|
995
1120
|
);
|
|
996
1121
|
}
|
|
997
1122
|
const token = await this.getSdkToken(options.userId);
|
|
998
1123
|
const { apiUrl } = this.config;
|
|
999
1124
|
const fetchImpl = this.transport.fetch ?? ((input, init) => fetch(input, init));
|
|
1000
|
-
const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto) : void 0;
|
|
1125
|
+
const signer = this.deviceBinding ? () => signCapture(fetchImpl, apiUrl, token, options.userId, options.idPhoto, options.idPhotoBack) : void 0;
|
|
1001
1126
|
return startKYC(apiUrl, token, options, this.transport, signer, this.livenessFlows);
|
|
1002
1127
|
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The company's capture settings, set on the AttestID dashboard. Read
|
|
1130
|
+
* `document_back` before the document step to know whether to ask for the
|
|
1131
|
+
* back of a national ID / driver's licence (see DocumentBackPolicy).
|
|
1132
|
+
*/
|
|
1133
|
+
async getCaptureSettings(userId) {
|
|
1134
|
+
const token = await this.getSdkToken(userId);
|
|
1135
|
+
return getCaptureSettings(this.config.apiUrl, token, this.transport);
|
|
1136
|
+
}
|
|
1003
1137
|
/**
|
|
1004
1138
|
* Fetch temporary AWS credentials and the Cognito identity pool ID from the
|
|
1005
1139
|
* backend. Call this alongside startVerification() for the same userId to
|
|
@@ -2317,6 +2451,7 @@ function clearHandoffParamsFromAddressBar() {
|
|
|
2317
2451
|
buildHandoffUrl,
|
|
2318
2452
|
canRetryLiveness,
|
|
2319
2453
|
canonicalJson,
|
|
2454
|
+
checkBackPhotoQuality,
|
|
2320
2455
|
checkCaptureConditions,
|
|
2321
2456
|
checkPhotoQuality,
|
|
2322
2457
|
classifyConditions,
|