@gerege-systems/eid-mongolia-sdk 0.13.0 → 0.14.1

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/dist/index.js CHANGED
@@ -138,6 +138,11 @@ var RP_ROUTES = {
138
138
  authDeviceLinkEtsi: { method: "POST", path: "/authentication/device-link/etsi/{semanticsIdentifier}" },
139
139
  signNotificationEtsi: { method: "POST", path: "/signature/notification/etsi/{semanticsIdentifier}" },
140
140
  signNotificationDocument: { method: "POST", path: "/signature/notification/document/{documentNumber}" },
141
+ signNotificationLinked: { method: "POST", path: "/signature/notification/linked/{documentNumber}" },
142
+ signDeviceLinkEtsi: { method: "POST", path: "/signature/device-link/etsi/{semanticsIdentifier}" },
143
+ signDeviceLinkDocument: { method: "POST", path: "/signature/device-link/document/{documentNumber}" },
144
+ certChoiceDeviceLink: { method: "POST", path: "/signature/certificate-choice/device-link/anonymous" },
145
+ certChoiceEtsi: { method: "POST", path: "/signature/certificate-choice/notification/etsi/{semanticsIdentifier}" },
141
146
  pdfPrepare: { method: "POST", path: "/pdf/sign/prepare" },
142
147
  pdfDocument: { method: "GET", path: "/pdf/sign/{sessionID}/document" },
143
148
  orgRepresentations: { method: "GET", path: "/organization/representations/etsi/{personEtsi}" },
@@ -167,13 +172,6 @@ function routePath(route, ...args) {
167
172
  if (i !== args.length) throw new Error(`eid: route ${route.path}: \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440 \u0438\u043B\u04AF\u04AF`);
168
173
  return out;
169
174
  }
170
- var OIDC_EXPECT = {
171
- defaultScope: "openid",
172
- pkceMethod: "S256",
173
- idTokenAlg: "ES256",
174
- /** `Authorization: Basic` (RFC 6749 §2.3.1). */
175
- tokenEndpointAuthMethod: "client_secret_basic"
176
- };
177
175
 
178
176
  // src/crypto.ts
179
177
  import { createHash, randomBytes } from "crypto";
@@ -271,7 +269,8 @@ var AuthApi = class {
271
269
  vc: vc != null ? String(vc) : null,
272
270
  rpChallenge: acsp.rpChallenge,
273
271
  acsp,
274
- receivedAt: Date.now()
272
+ receivedAt: Date.now(),
273
+ sessionType: "auth"
275
274
  };
276
275
  }
277
276
  /** Push — РД/иргэний дугаар/ETSI-ээр (сервер төрлийг таьна). Иргэний утас руу мэдэгдэл очно. */
@@ -300,10 +299,20 @@ var AuthApi = class {
300
299
  }
301
300
  };
302
301
 
302
+ // src/types.ts
303
+ var DEFAULT_SIGN_SESSION_TTL_MS = 6e5;
304
+ function sessionExpiry(sentAtMs, ttlMs) {
305
+ return sentAtMs + (ttlMs && ttlMs > 0 ? ttlMs : DEFAULT_SIGN_SESSION_TTL_MS);
306
+ }
307
+
303
308
  // src/sign.ts
304
309
  function rec2(v) {
305
310
  return v && typeof v === "object" ? v : {};
306
311
  }
312
+ var vcOf = (r) => {
313
+ const vc = typeof r.vc === "object" && r.vc !== null ? rec2(r.vc).value : r.vc;
314
+ return vc != null ? String(vc) : null;
315
+ };
307
316
  var SignApi = class {
308
317
  constructor(http, creds, defaults) {
309
318
  this.http = http;
@@ -313,46 +322,155 @@ var SignApi = class {
313
322
  http;
314
323
  creds;
315
324
  defaults;
316
- body(digestB64, interactions, opts) {
325
+ body(digestB64, interactions, opts, defAlg) {
326
+ const sign = {
327
+ digest: digestB64,
328
+ hashAlgorithm: opts?.hashAlgorithm ?? "SHA-256",
329
+ signatureAlgorithm: opts?.signatureAlgorithm ?? defAlg,
330
+ relyingPartyName: this.creds.rpName,
331
+ interactions: interactionsToBase64(interactions),
332
+ initialCallbackUrl: opts?.callbackUrl?.trim() ?? ""
333
+ };
317
334
  const body = {
318
335
  relyingPartyUUID: this.creds.rpUUID,
319
- relyingPartyName: this.creds.rpName,
336
+ relyingPartyName: sign.relyingPartyName,
320
337
  certificateLevel: opts?.certificateLevel ?? this.defaults.certificateLevel,
321
338
  signatureProtocol: "RAW_DIGEST_SIGNATURE",
322
339
  signatureProtocolParameters: {
323
340
  digest: digestB64,
324
- signatureAlgorithm: opts?.signatureAlgorithm ?? "rsassa-pss",
325
- signatureAlgorithmParameters: { hashAlgorithm: opts?.hashAlgorithm ?? "SHA-256" }
341
+ signatureAlgorithm: sign.signatureAlgorithm,
342
+ signatureAlgorithmParameters: { hashAlgorithm: sign.hashAlgorithm }
326
343
  },
327
- interactions: interactionsToBase64(interactions)
344
+ interactions: sign.interactions
328
345
  };
329
- if (opts?.callbackUrl?.trim()) body.initialCallbackUrl = opts.callbackUrl.trim();
346
+ if (sign.initialCallbackUrl) body.initialCallbackUrl = sign.initialCallbackUrl;
330
347
  if (opts?.onBehalfOf?.trim()) body.onBehalfOf = opts.onBehalfOf.trim();
331
348
  if (opts?.fileName?.trim()) body.fileName = opts.fileName.trim();
332
- return body;
349
+ return { body, sign };
333
350
  }
334
- parse(raw) {
335
- const r = rec2(raw);
336
- const vc = rec2(r.vc).value;
337
- return { sessionId: String(r.sessionID), vc: vc != null ? String(vc) : null, rpChallenge: "" };
351
+ async notification(path, body, sign) {
352
+ const sent = Date.now();
353
+ const r = rec2(await this.http.post(path, body));
354
+ return {
355
+ sessionId: String(r.sessionID),
356
+ vc: vcOf(r),
357
+ rpChallenge: "",
358
+ ...sign ? { sign } : {},
359
+ expiresAt: sessionExpiry(sent, this.defaults.sessionTtlMs)
360
+ };
361
+ }
362
+ async deviceLink(path, body, extra) {
363
+ const sent = Date.now();
364
+ const r = rec2(await this.http.post(path, body));
365
+ return {
366
+ sessionId: String(r.sessionID),
367
+ sessionToken: r.sessionToken != null ? String(r.sessionToken) : null,
368
+ sessionSecret: r.sessionSecret != null ? String(r.sessionSecret) : null,
369
+ deviceLinkBase: r.deviceLinkBase != null ? String(r.deviceLinkBase) : null,
370
+ vc: vcOf(r),
371
+ rpChallenge: "",
372
+ receivedAt: Date.now(),
373
+ ...extra,
374
+ expiresAt: sessionExpiry(sent, this.defaults.sessionTtlMs)
375
+ };
338
376
  }
339
377
  /**
340
378
  * Бэлэн digest-ээр push sign (РД/civil_id/ETSI). Баримт (PDF/файл)-ын байтын SHA-256
341
379
  * digest-ийг гаднаас бэлдэж өгнө — текст бус бинар баримтад тохиромжтой.
380
+ *
381
+ * ⚠️ Олон утастай иргэнд CA etsi-г ЭХНИЙ идэвхтэй төхөөрөмж рүү шийднэ. Гэрчилгээг урьдчилж мэдэх шаардлагатай бол
382
+ * (PAdES/CMS) {@link certificateChoiceByEtsi} → {@link digestForSigner}.
342
383
  */
343
384
  async digestByEtsi(id, digestB64, interactions, opts) {
344
- const raw = await this.http.post(routePath(RP_ROUTES.signNotificationEtsi, id), this.body(digestB64, interactions, opts));
345
- return this.parse(raw);
385
+ const { body, sign } = this.body(digestB64, interactions, opts, "rsassa-pss");
386
+ return this.notification(routePath(RP_ROUTES.signNotificationEtsi, id), body, sign);
346
387
  }
347
- /** Бэлэн digest-ээр push sign — баримтын дугаараар (auth-аас гарсан documentNumber дээр). */
388
+ /** Бэлэн digest-ээр push sign — баримтын дугаараар (auth / certificate-choice-оос гарсан documentNumber дээр). */
348
389
  async digestByDocument(documentNumber, digestB64, interactions, opts) {
349
- const raw = await this.http.post(routePath(RP_ROUTES.signNotificationDocument, documentNumber), this.body(digestB64, interactions, opts));
350
- return this.parse(raw);
390
+ const { body, sign } = this.body(digestB64, interactions, opts, "rsassa-pss");
391
+ return this.notification(routePath(RP_ROUTES.signNotificationDocument, documentNumber), body, sign);
351
392
  }
352
393
  /** Текстийн SHA-256 digest-ийг дотроо тооцоод push sign (богино текст баримтад тохиромжтой). */
353
394
  async textByEtsi(id, text, interactions, opts) {
354
395
  return this.digestByEtsi(id, sha256Base64(text), interactions, opts);
355
396
  }
397
+ /**
398
+ * QR / Web2App / App2App sign — тодорхой иргэн (РД/civil_id/ETSI). Холбоосыг backend-д
399
+ * `buildDeviceLink(deviceLinkInput(s, type, lang))`-ээр; Web2App/App2App-д `callbackUrl` заавал.
400
+ * `validateSign`-д `expectedFlowType` = харуулсан холбоосын төрөл.
401
+ */
402
+ async deviceLinkByEtsi(id, digestB64, interactions, opts) {
403
+ const { body, sign } = this.body(digestB64, interactions, opts, "rsassa-pss");
404
+ return this.deviceLink(routePath(RP_ROUTES.signDeviceLinkEtsi, id), body, { sessionType: "sign", sign });
405
+ }
406
+ /** QR / Web2App / App2App sign — баримтын дугаараар (тодорхой төхөөрөмж). */
407
+ async deviceLinkByDocument(documentNumber, digestB64, interactions, opts) {
408
+ const { body, sign } = this.body(digestB64, interactions, opts, "rsassa-pss");
409
+ return this.deviceLink(routePath(RP_ROUTES.signDeviceLinkDocument, documentNumber), body, { sessionType: "sign", sign });
410
+ }
411
+ // ── Certificate-choice ──
412
+ certChoiceBody(opts) {
413
+ return {
414
+ relyingPartyUUID: this.creds.rpUUID,
415
+ relyingPartyName: this.creds.rpName,
416
+ certificateLevel: opts?.certificateLevel ?? this.defaults.certificateLevel
417
+ };
418
+ }
419
+ /**
420
+ * Push: иргэний (РД/civil_id/ETSI) утас руу гарын үсгийн гэрчилгээ сонгуулах хүсэлт (PIN-гүй). Үр дүнг
421
+ * `session.waitForResult` → `validator.validateCertificateChoice`. CERT эрх шаардана.
422
+ */
423
+ async certificateChoiceByEtsi(id, opts) {
424
+ return this.notification(routePath(RP_ROUTES.certChoiceEtsi, id), this.certChoiceBody(opts));
425
+ }
426
+ /**
427
+ * QR: иргэн хэн болохыг урьдчилж мэдэхгүй үед гэрчилгээ сонгуулах. CA-ийн certificate-choice хүсэлт
428
+ * initialCallbackUrl авдаггүй тул ЗӨВХӨН QR холбоос (`deviceLinkInput(s, "QR", lang)`). CERT эрх.
429
+ */
430
+ async certificateChoiceDeviceLink(opts) {
431
+ return this.deviceLink(routePath(RP_ROUTES.certChoiceDeviceLink), this.certChoiceBody(opts), {
432
+ sessionType: "cert",
433
+ relyingPartyName: this.creds.rpName
434
+ });
435
+ }
436
+ // ── Гэрчилгээ урьдчилж мэдэгдэх гарын үсэг (client-side PAdES / CMS) ──
437
+ signerBody(signer, digestB64, interactions, opts) {
438
+ if (!signer?.documentNumber?.trim()) {
439
+ throw new Error("eid: signer.documentNumber \u0445\u043E\u043E\u0441\u043E\u043D \u2014 validateCertificateChoice-\u0438\u0439\u043D \u04AF\u0440 \u0434\u04AF\u043D\u0433 \u04E9\u0433");
440
+ }
441
+ const out = this.body(digestB64, interactions, opts, "sha256WithRSAEncryption");
442
+ const want = hashLen(out.sign.hashAlgorithm);
443
+ const d = /^[A-Za-z0-9+/]*={0,2}$/.test(digestB64) ? Buffer.from(digestB64, "base64") : null;
444
+ if (!d || d.length !== want || d.toString("base64") !== digestB64) {
445
+ throw new Error(`eid: digest \u043D\u044C \u0441\u0442\u0430\u043D\u0434\u0430\u0440\u0442 base64, ${out.sign.hashAlgorithm}-\u0438\u0439\u043D \u0443\u0440\u0442 (${want} \u0431\u0430\u0439\u0442) \u0431\u0430\u0439\u0445 \u0451\u0441\u0442\u043E\u0439`);
446
+ }
447
+ return out;
448
+ }
449
+ /**
450
+ * Certificate-choice-оор сонгосон ЯГ ТЭР төхөөрөмж (`signer.documentNumber`) рүү push sign. Олон утастай иргэнд
451
+ * etsi-ээр эхлүүлбэл өөр утас (өөр гэрчилгээ) зурж, гэрчилгээг урьдчилж оруулсан CMS (PAdES signingCertificateV2)
452
+ * хүчингүй болдог — тиймээс digest-ийг ҮРГЭЛЖ documentNumber-аар зуруулна.
453
+ *
454
+ * `signer.certChoiceSessionId` байвал `/signature/notification/linked/{documentNumber}` (`linkedSessionID`), эс
455
+ * бөгөөс `/signature/notification/document/{documentNumber}`. Default `sha256WithRSAEncryption` + SHA-256 (digest =
456
+ * 32 байт DTBS); digest-ийн урт буруу бол хүсэлт явахгүй. Хариуг:
457
+ * `validator.validateSign(r, s.sign.digest, { hashAlgorithm: s.sign.hashAlgorithm, expectedAlgorithm:
458
+ * s.sign.signatureAlgorithm, expectedCertificate: signer.certificate, expectedFlowType: "Notification" })`.
459
+ */
460
+ async digestForSigner(signer, digestB64, interactions, opts) {
461
+ const { body, sign } = this.signerBody(signer, digestB64, interactions, opts);
462
+ const linked = signer.certChoiceSessionId?.trim();
463
+ if (linked) {
464
+ body.linkedSessionID = linked;
465
+ return this.notification(routePath(RP_ROUTES.signNotificationLinked, signer.documentNumber), body, sign);
466
+ }
467
+ return this.notification(routePath(RP_ROUTES.signNotificationDocument, signer.documentNumber), body, sign);
468
+ }
469
+ /** {@link digestForSigner}-ийн QR / Web2App / App2App хувилбар: `/signature/device-link/document/{documentNumber}`. */
470
+ async deviceLinkForSigner(signer, digestB64, interactions, opts) {
471
+ const { body, sign } = this.signerBody(signer, digestB64, interactions, opts);
472
+ return this.deviceLink(routePath(RP_ROUTES.signDeviceLinkDocument, signer.documentNumber), body, { sessionType: "sign", sign });
473
+ }
356
474
  };
357
475
 
358
476
  // src/session.ts
@@ -1113,13 +1231,19 @@ var ResponseValidator = class {
1113
1231
  * @param opts — `hashAlgorithm` мөр (хуучин хэлбэр; default SHA-256) эсвэл {@link ValidateSignOptions}.
1114
1232
  */
1115
1233
  validateSign(result, digestB64, opts = {}) {
1116
- const { hashAlgorithm = "SHA-256", expectedFlowType } = typeof opts === "string" ? { hashAlgorithm: opts } : opts;
1234
+ const o = typeof opts === "string" ? { hashAlgorithm: opts } : opts;
1235
+ const { hashAlgorithm = "SHA-256", expectedFlowType } = o;
1117
1236
  const leaf = this.precheck(result, "RAW_DIGEST_SIGNATURE");
1118
1237
  if (expectedFlowType && result.flowType !== expectedFlowType) {
1119
1238
  throw new ValidationError(`flowType ${result.flowType} \u2260 \u044D\u0445\u043B\u04AF\u04AF\u043B\u0441\u044D\u043D \u0443\u0440\u0441\u0433\u0430\u043B ${expectedFlowType} (relay \u0431\u0430\u0439\u0436 \u0431\u043E\u043B\u0437\u043E\u0448\u0433\u04AF\u0439)`);
1120
1239
  }
1240
+ if (o.expectedCertificate && !leaf.raw.equals(o.expectedCertificate.raw)) {
1241
+ throw new ValidationError(
1242
+ `\u0413\u044D\u0440\u0447\u0438\u043B\u0433\u044D\u044D certificate-choice-\u043E\u043E\u0440 \u0441\u043E\u043D\u0433\u043E\u0441\u043E\u043D\u0442\u043E\u0439 \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439 (serial ${leaf.serialNumber} \u2260 ${o.expectedCertificate.serialNumber})`
1243
+ );
1244
+ }
1121
1245
  const digest = Buffer.from(digestB64, "base64");
1122
- const { alg, hash } = this.algorithmOf(result, leaf, null, hashAlgorithm);
1246
+ const { alg, hash } = this.algorithmOf(result, leaf, o.expectedAlgorithm ?? null, hashAlgorithm);
1123
1247
  if (digest.length !== hashLen(hash)) throw new ValidationError("digest \u0443\u0440\u0442 hashAlgorithm-\u0442\u0430\u0439 \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439");
1124
1248
  if (!verifyPrehashedSignature(digest, result.signatureValueB64, leaf.publicKey, alg, hash)) {
1125
1249
  throw new ValidationError("\u0413\u0430\u0440\u044B\u043D \u04AF\u0441\u044D\u0433 \u04E9\u0433\u0441\u04E9\u043D digest-\u0442\u044D\u0439 \u0442\u0430\u0430\u0440\u0441\u0430\u043D\u0433\u04AF\u0439");
@@ -1133,6 +1257,24 @@ var ResponseValidator = class {
1133
1257
  parsedSubject: parseSubject(leaf)
1134
1258
  };
1135
1259
  }
1260
+ /**
1261
+ * Certificate-choice session-ий хариуг шалгана: COMPLETE/OK (эс бөгөөс `SessionFailedError`), гэрчилгээ ба
1262
+ * documentNumber байгаа, хугацаа, certLevel, зориулалт (гарын үсгийн — contentCommitment, `checkKeyUsage` үед), гинж,
1263
+ * issuer. Энэ хариунд гарын үсэг БАЙХГҮЙ — иргэнийг таниагүй (нэвтрэлт биш); зөвхөн дараагийн sign session-ий
1264
+ * гэрчилгээг урьдчилж мэдэхэд. `sessionId` — certificate-choice session.
1265
+ */
1266
+ validateCertificateChoice(result, sessionId = null) {
1267
+ this.sessionDone(result);
1268
+ const leaf = this.leafOf(result, "RAW_DIGEST_SIGNATURE");
1269
+ return {
1270
+ documentNumber: result.documentNumber,
1271
+ certificate: leaf,
1272
+ certificateDerB64: leaf.raw.toString("base64"),
1273
+ certificateLevel: String(result.certificateLevel ?? ""),
1274
+ parsedSubject: parseSubject(leaf),
1275
+ certChoiceSessionId: sessionId
1276
+ };
1277
+ }
1136
1278
  /**
1137
1279
  * Хариуны signatureAlgorithm + hashAlgorithm-ийг RP-ийн хүссэнтэй ба иргэний түлхүүрийн төрөлтэй тулгана
1138
1280
  * (downgrade / alg confusion хаалт).
@@ -1182,13 +1324,22 @@ var ResponseValidator = class {
1182
1324
  // ── дотоод ──
1183
1325
  /** state/endResult, cert байгаа эсэх, chain, validity, certLevel — нийтлэг шалгалт. */
1184
1326
  precheck(result, protocol) {
1185
- if (result.state !== "COMPLETE") throw new ValidationError(`Session \u0434\u0443\u0443\u0441\u0430\u0430\u0433\u04AF\u0439 (state=${result.state})`);
1186
- if (result.endResult !== "OK") throw new SessionFailedError(result.endResult ?? "UNKNOWN");
1327
+ this.sessionDone(result);
1187
1328
  if (result.signatureProtocol && result.signatureProtocol !== protocol) {
1188
1329
  throw new ValidationError(`signatureProtocol ${result.signatureProtocol} \u2260 ${protocol}`);
1189
1330
  }
1190
1331
  if (!result.certificateDerB64) throw new ValidationError("\u0425\u0430\u0440\u0438\u0443\u043D\u0434 \u0433\u044D\u0440\u0447\u0438\u043B\u0433\u044D\u044D \u0430\u043B\u0433\u0430");
1191
1332
  if (!result.signatureValueB64) throw new ValidationError("\u0425\u0430\u0440\u0438\u0443\u043D\u0434 \u0433\u0430\u0440\u044B\u043D \u04AF\u0441\u044D\u0433 \u0430\u043B\u0433\u0430");
1333
+ return this.leafOf(result, protocol);
1334
+ }
1335
+ /** COMPLETE + OK; амжилтгүй endResult → SessionFailedError. */
1336
+ sessionDone(result) {
1337
+ if (result.state !== "COMPLETE") throw new ValidationError(`Session \u0434\u0443\u0443\u0441\u0430\u0430\u0433\u04AF\u0439 (state=${result.state})`);
1338
+ if (result.endResult !== "OK") throw new SessionFailedError(result.endResult ?? "UNKNOWN");
1339
+ }
1340
+ /** Хариуны гэрчилгээ: documentNumber, задлалт, хугацаа, certLevel, зориулалт (protocol), гинж, issuer. */
1341
+ leafOf(result, protocol) {
1342
+ if (!result.certificateDerB64) throw new ValidationError("\u0425\u0430\u0440\u0438\u0443\u043D\u0434 \u0433\u044D\u0440\u0447\u0438\u043B\u0433\u044D\u044D \u0430\u043B\u0433\u0430");
1192
1343
  if (!result.documentNumber) throw new ValidationError("\u0425\u0430\u0440\u0438\u0443\u043D\u0434 documentNumber \u0430\u043B\u0433\u0430");
1193
1344
  const leaf = new X509Certificate3(Buffer.from(result.certificateDerB64, "base64"));
1194
1345
  this.checkValidity(leaf);
@@ -1590,6 +1741,8 @@ var EidClient = class {
1590
1741
  token;
1591
1742
  session;
1592
1743
  validator;
1744
+ /** `credentials.rpName` — иргэний утсанд харагдах RP нэр. */
1745
+ rpName;
1593
1746
  constructor(cfg) {
1594
1747
  if (!cfg.credentials?.apiSecret) throw new Error("EidClient: credentials.apiSecret \u0448\u0430\u0430\u0440\u0434\u043B\u0430\u0433\u0430\u0442\u0430\u0439");
1595
1748
  const base = resolveBaseUrl(cfg.baseUrl);
@@ -1603,7 +1756,7 @@ var EidClient = class {
1603
1756
  });
1604
1757
  const level = cfg.defaultCertificateLevel ?? "QUALIFIED";
1605
1758
  this.auth = new AuthApi(http, cfg.credentials, { certificateLevel: level });
1606
- this.sign = new SignApi(http, cfg.credentials, { certificateLevel: level });
1759
+ this.sign = new SignApi(http, cfg.credentials, { certificateLevel: level, sessionTtlMs: cfg.signSessionTtlMs });
1607
1760
  this.pdf = new PdfApi(http, cfg.credentials, {
1608
1761
  certificateLevel: level,
1609
1762
  timeoutMs: cfg.pdfTimeoutMs ?? DEFAULT_PDF_TIMEOUT_MS,
@@ -1614,6 +1767,7 @@ var EidClient = class {
1614
1767
  this.token = new TokenApi(http, cfg.credentials);
1615
1768
  this.session = new SessionApi(http);
1616
1769
  this.validator = new ResponseValidator(cfg.trust ?? {});
1770
+ this.rpName = cfg.credentials.rpName;
1617
1771
  }
1618
1772
  };
1619
1773
 
@@ -1678,6 +1832,37 @@ function buildDeviceLink(p) {
1678
1832
  const authCode = createHmac("sha256", Buffer.from(p.sessionSecret, "base64")).update(payload, "utf8").digest("base64url");
1679
1833
  return `${unprotected}&authCode=${authCode}`;
1680
1834
  }
1835
+ function deviceLinkInput(s, deviceLinkType, lang) {
1836
+ const base = {
1837
+ deviceLinkBase: s.deviceLinkBase ?? "",
1838
+ deviceLinkType,
1839
+ sessionToken: s.sessionToken ?? "",
1840
+ sessionSecret: s.sessionSecret ?? "",
1841
+ lang,
1842
+ receivedAt: s.receivedAt
1843
+ };
1844
+ if (s.sessionType === "sign" && s.sign) {
1845
+ return {
1846
+ ...base,
1847
+ sessionType: "sign",
1848
+ digest: s.sign.digest,
1849
+ relyingPartyName: s.sign.relyingPartyName,
1850
+ interactions: s.sign.interactions,
1851
+ initialCallbackUrl: s.sign.initialCallbackUrl
1852
+ };
1853
+ }
1854
+ if (s.sessionType === "cert") {
1855
+ return { ...base, sessionType: "cert", relyingPartyName: s.relyingPartyName ?? "", interactions: "" };
1856
+ }
1857
+ return {
1858
+ ...base,
1859
+ sessionType: "auth",
1860
+ rpChallenge: s.acsp?.rpChallenge ?? s.rpChallenge,
1861
+ relyingPartyName: s.acsp?.relyingPartyName ?? "",
1862
+ interactions: s.acsp?.interactions ?? "",
1863
+ initialCallbackUrl: s.acsp?.initialCallbackUrl ?? ""
1864
+ };
1865
+ }
1681
1866
  function qrDeviceLinkTicker(input, onLink, opts) {
1682
1867
  const clock = opts?.clock ?? Date.now;
1683
1868
  const tick = () => onLink(buildDeviceLink({ ...input, deviceLinkType: "QR", now: clock() }));
@@ -1687,293 +1872,392 @@ function qrDeviceLinkTicker(input, onLink, opts) {
1687
1872
  return () => clearInterval(t);
1688
1873
  }
1689
1874
 
1690
- // src/oidc.ts
1691
- import { timingSafeEqual as timingSafeEqual2 } from "crypto";
1692
- var EID_OIDC_ISSUER = "https://login.eidmongolia.mn";
1693
- var EID_ACR_QUALIFIED = "urn:eidmongolia:loa:qualified";
1694
- var EID_ACR_ADVANCED = "urn:eidmongolia:loa:advanced";
1695
- var CLOCK_SKEW_S = 60;
1696
- var CACHE_MS = 60 * 60 * 1e3;
1697
- var DEFAULT_TIMEOUT_MS2 = 1e4;
1698
- var KNOWN_CODES = /* @__PURE__ */ new Set([
1699
- "access_denied",
1700
- "login_required",
1701
- "invalid_request",
1702
- "invalid_scope",
1703
- "unauthorized_client",
1704
- "unsupported_response_type",
1705
- "request_not_supported",
1706
- "temporarily_unavailable",
1707
- "server_error",
1708
- "invalid_client",
1709
- "invalid_grant",
1710
- "unsupported_grant_type"
1711
- ]);
1712
- var EidOidcError = class extends EidError {
1713
- constructor(code, message, description) {
1714
- super(message);
1715
- this.code = code;
1716
- this.description = description;
1717
- }
1718
- code;
1719
- description;
1720
- };
1721
- var EidOidcClient = class {
1722
- constructor(cfg) {
1723
- this.cfg = cfg;
1724
- if (!cfg.clientId) throw new EidError("EidOidcClient: clientId \u0445\u043E\u043E\u0441\u043E\u043D");
1725
- if (!cfg.clientSecret) throw new EidError("EidOidcClient: clientSecret \u0445\u043E\u043E\u0441\u043E\u043D");
1726
- if (!/^https:\/\//.test(cfg.redirectUri ?? "")) throw new EidError("EidOidcClient: redirectUri https:// \u0431\u0430\u0439\u0445 \u0451\u0441\u0442\u043E\u0439");
1727
- this.issuer = (cfg.issuer ?? EID_OIDC_ISSUER).replace(/\/+$/, "");
1728
- this.scope = cfg.scope ?? OIDC_EXPECT.defaultScope;
1729
- }
1730
- cfg;
1731
- issuer;
1732
- scope;
1733
- discovery;
1734
- jwks;
1735
- /** Discovery document (1 цаг cache). `issuer` нь тохиргоотой яг таарах ёстой (OIDC Discovery §4.3). */
1736
- discover() {
1737
- const c = this.discovery;
1738
- if (c && Date.now() - c.at < CACHE_MS) return c.d;
1739
- const d = this.fetchDiscovery();
1740
- this.discovery = { at: Date.now(), d };
1741
- d.catch(() => {
1742
- if (this.discovery?.d === d) this.discovery = void 0;
1743
- });
1744
- return d;
1745
- }
1746
- async fetchDiscovery() {
1747
- const d = await this.getJson(this.issuer + "/.well-known/openid-configuration");
1748
- if (!d || d.issuer !== this.issuer) {
1749
- throw new EidOidcError("discovery_failed", `OIDC discovery-\u0438\u0439\u043D issuer \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439 (\u0445\u04AF\u043B\u044D\u044D\u0441\u044D\u043D ${this.issuer})`);
1750
- }
1751
- for (const k of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) {
1752
- if (typeof d[k] !== "string" || !d[k].startsWith("https://")) {
1753
- throw new EidOidcError("discovery_failed", `OIDC discovery: ${k} \u0431\u0443\u0440\u0443\u0443`);
1875
+ // src/login.ts
1876
+ import { createHash as createHash5, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1877
+ function memoryLoginStore() {
1878
+ const m = /* @__PURE__ */ new Map();
1879
+ return {
1880
+ get(k) {
1881
+ const e = m.get(k);
1882
+ if (!e) return void 0;
1883
+ if (e.exp <= Date.now()) {
1884
+ m.delete(k);
1885
+ return void 0;
1754
1886
  }
1887
+ return e.tx;
1888
+ },
1889
+ set(k, tx, ttl) {
1890
+ const now = Date.now();
1891
+ if (m.size > 1e3) {
1892
+ for (const [key, e] of m) if (e.exp <= now) m.delete(key);
1893
+ }
1894
+ m.set(k, { tx, exp: now + ttl });
1895
+ },
1896
+ delete(k) {
1897
+ return m.delete(k);
1898
+ }
1899
+ };
1900
+ }
1901
+ var CALLBACK_PATH = "/auth/eid/callback";
1902
+ function forwardedClientIp(headers, trustProxy) {
1903
+ if (trustProxy === false || trustProxy === 0) return void 0;
1904
+ const xff = header(headers, "x-forwarded-for").split(",").map((x) => x.trim()).filter(Boolean);
1905
+ if (xff.length) {
1906
+ if (trustProxy === true) return xff[0];
1907
+ const n = Math.floor(trustProxy);
1908
+ return n >= 1 && n <= xff.length ? xff[xff.length - n] : void 0;
1909
+ }
1910
+ return header(headers, "x-real-ip").trim() || void 0;
1911
+ }
1912
+ var ID_RE = /^(\p{L}{2}[0-9]{8}|[0-9]{8,14}|PNOMN-[0-9]{8,14})$/u;
1913
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
1914
+ var B64URL_RE = /^[A-Za-z0-9_-]{43}$/;
1915
+ var IOS_BROWSERS = [
1916
+ [/CriOS/i, "googlechromes"],
1917
+ [/EdgiOS/i, "microsoft-edge-https"],
1918
+ [/FxiOS/i, "firefox"],
1919
+ [/OPT\//i, "touch-https"],
1920
+ [/OPiOS|OPR\//i, "opera-https"],
1921
+ [/DuckDuckGo/i, "ddgQuickLink"]
1922
+ ];
1923
+ var ANDROID_BROWSERS = [
1924
+ [/EdgA\//i, "com.microsoft.emmx"],
1925
+ [/Opera Mini/i, "com.opera.mini.native"],
1926
+ [/OPR\//i, "com.opera.browser"],
1927
+ [/SamsungBrowser\//i, "com.sec.android.app.sbrowser"],
1928
+ [/YaBrowser\//i, "com.yandex.browser"],
1929
+ [/DuckDuckGo\//i, "com.duckduckgo.mobile.android"],
1930
+ [/Vivaldi\//i, "com.vivaldi.browser"],
1931
+ [/Firefox\//i, "org.mozilla.firefox"],
1932
+ [/Chrome\//i, "com.android.chrome"]
1933
+ ];
1934
+ function withReturnHint(callbackUrl, userAgent) {
1935
+ const u = new URL(callbackUrl);
1936
+ const pick = (t) => t.find(([re]) => re.test(userAgent))?.[1];
1937
+ if (/iPhone|iPad|iPod/i.test(userAgent)) {
1938
+ const v = pick(IOS_BROWSERS);
1939
+ if (v) u.searchParams.set("retScheme", v);
1940
+ } else if (/Android/i.test(userAgent)) {
1941
+ const v = pick(ANDROID_BROWSERS);
1942
+ if (v) u.searchParams.set("retPkg", v);
1943
+ }
1944
+ return u.toString();
1945
+ }
1946
+ function header(h, name) {
1947
+ const v = h[name] ?? h[name.toLowerCase()];
1948
+ return (Array.isArray(v) ? v[0] : v) ?? "";
1949
+ }
1950
+ function readCookie(h, name) {
1951
+ for (const part of header(h, "cookie").split(";")) {
1952
+ const i = part.indexOf("=");
1953
+ if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
1954
+ }
1955
+ return "";
1956
+ }
1957
+ var sha256hex = (s) => createHash5("sha256").update(s).digest("hex");
1958
+ function safeEqualHex(a, b) {
1959
+ const x = Buffer.from(a, "hex");
1960
+ const y = Buffer.from(b, "hex");
1961
+ return x.length === y.length && x.length > 0 && timingSafeEqual2(x, y);
1962
+ }
1963
+ function toIdentity(sessionId, r, v, flowType) {
1964
+ const s = v.parsedSubject;
1965
+ const etsi = s.serialNumber ?? "";
1966
+ const m = /^PNOMN-([0-9]+)$/.exec(etsi);
1967
+ return {
1968
+ sessionId,
1969
+ etsi,
1970
+ civilId: m ? m[1] : null,
1971
+ givenName: s.givenName ?? null,
1972
+ surname: s.surname ?? null,
1973
+ name: s.name,
1974
+ country: s.country ?? null,
1975
+ documentNumber: v.documentNumber,
1976
+ certificateLevel: v.certificateLevel,
1977
+ certificateDerB64: r.certificateDerB64 ?? "",
1978
+ flowType,
1979
+ person: personMatchesCertificate(r.person, s) ? r.person ?? null : null
1980
+ };
1981
+ }
1982
+ function createEidLogin(opts) {
1983
+ const client = opts.client;
1984
+ if (!opts.onSuccess) throw new Error("createEidLogin: onSuccess \u0448\u0430\u0430\u0440\u0434\u043B\u0430\u0433\u0430\u0442\u0430\u0439");
1985
+ if (opts.callbackUrl) {
1986
+ const u = new URL(opts.callbackUrl);
1987
+ if (u.protocol !== "https:" || u.pathname !== CALLBACK_PATH) {
1988
+ throw new Error(`createEidLogin: callbackUrl \u043D\u044C https://<\u0442\u0430\u043D\u044B host>${CALLBACK_PATH} \u0431\u0430\u0439\u0445 \u0451\u0441\u0442\u043E\u0439`);
1755
1989
  }
1756
- return d;
1757
1990
  }
1758
- /**
1759
- * Authorize URL + санамсаргүй state / nonce / PKCE codeVerifier. RP буцаасан `state`, `nonce`, `codeVerifier`-ийг
1760
- * хөтчийн session-тэй холбож (httpOnly cookie эсвэл server session) хадгалаад хөтчийг `url` руу 302 хийнэ.
1761
- */
1762
- async createAuthorization(opts = {}) {
1763
- const d = await this.discover();
1764
- const state = randomB64url(32);
1765
- const nonce = randomB64url(32);
1766
- const codeVerifier = randomB64url(32);
1767
- const u = new URL(d.authorization_endpoint);
1768
- const p = new URLSearchParams({
1769
- response_type: "code",
1770
- client_id: this.cfg.clientId,
1771
- redirect_uri: this.cfg.redirectUri,
1772
- scope: opts.scope ?? this.scope,
1773
- state,
1774
- nonce,
1775
- code_challenge: await pkceChallenge(codeVerifier),
1776
- code_challenge_method: OIDC_EXPECT.pkceMethod
1991
+ const store = opts.store ?? memoryLoginStore();
1992
+ const ttl = opts.ttlMs ?? 12e4;
1993
+ const pollMs = opts.pollMs ?? 1e3;
1994
+ const cookieName = opts.cookie?.name ?? "eid_login";
1995
+ const cookiePath = opts.cookie?.path ?? "/";
1996
+ const secure = opts.cookie?.secure ?? true;
1997
+ const successRedirect = opts.successRedirect ?? "/";
1998
+ const failureRedirect = opts.failureRedirect ?? "/";
1999
+ const onError = opts.onError ?? ((e) => console.error("eid-login:", e instanceof Error ? e.message : e));
2000
+ const pushHits = /* @__PURE__ */ new Map();
2001
+ const rateLimit = opts.rateLimit ?? ((key) => {
2002
+ if (!key.startsWith("push:")) return true;
2003
+ const now = Date.now();
2004
+ const hits = (pushHits.get(key) ?? []).filter((t) => now - t < 6e4);
2005
+ if (pushHits.size > 1e4) pushHits.clear();
2006
+ if (hits.length >= 3) return false;
2007
+ hits.push(now);
2008
+ pushHits.set(key, hits);
2009
+ return true;
2010
+ });
2011
+ const displayText = (opts.displayText ?? `${client.rpName}-\u0434 \u043D\u044D\u0432\u0442\u0440\u044D\u0445`).slice(0, 60);
2012
+ const interactions = [{ type: "displayTextAndPIN", displayText60: displayText }];
2013
+ const json = (status2, body, extra = {}) => ({
2014
+ status: status2,
2015
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...extra },
2016
+ body: JSON.stringify(body)
2017
+ });
2018
+ const cookieAttrs = (maxAge) => `Path=${cookiePath}; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}` + (opts.cookie?.domain ? `; Domain=${opts.cookie.domain}` : "");
2019
+ const clearCookie = `${cookieName}=; ${cookieAttrs(0)}`;
2020
+ const redirect = (to, extra = {}) => ({
2021
+ status: 303,
2022
+ headers: { Location: to, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", ...extra },
2023
+ body: ""
2024
+ });
2025
+ const failTo = (code) => {
2026
+ const u = new URL(failureRedirect, "http://x");
2027
+ u.searchParams.set("eid_error", code);
2028
+ return /^https?:/i.test(failureRedirect) ? u.toString() : u.pathname + u.search + u.hash;
2029
+ };
2030
+ const mergeHeaders = (a, b) => {
2031
+ const out = { ...a };
2032
+ for (const [k, v] of Object.entries(b ?? {})) {
2033
+ const key = Object.keys(out).find((x) => x.toLowerCase() === k.toLowerCase()) ?? k;
2034
+ const prev = out[key];
2035
+ out[key] = key.toLowerCase() === "set-cookie" && prev ? [...[prev].flat(), ...[v].flat()] : v;
2036
+ }
2037
+ return out;
2038
+ };
2039
+ async function loadTx(req) {
2040
+ const raw = readCookie(req.headers, cookieName);
2041
+ const dot = raw.indexOf(".");
2042
+ const sid = raw.slice(0, dot);
2043
+ const binding = raw.slice(dot + 1);
2044
+ if (dot < 0 || !UUID_RE.test(sid) || !B64URL_RE.test(binding)) return void 0;
2045
+ const tx = await store.get(sid);
2046
+ if (!tx || !safeEqualHex(tx.bindingHash, sha256hex(binding))) return void 0;
2047
+ if (tx.expiresAt <= Date.now()) {
2048
+ await store.delete(sid);
2049
+ return void 0;
2050
+ }
2051
+ return tx;
2052
+ }
2053
+ function qrLink(tx) {
2054
+ if (tx.mode !== "qr" || !tx.link) return void 0;
2055
+ return buildDeviceLink({
2056
+ deviceLinkBase: tx.link.base,
2057
+ deviceLinkType: "QR",
2058
+ sessionToken: tx.link.token,
2059
+ sessionSecret: tx.link.secret,
2060
+ sessionType: "auth",
2061
+ lang: tx.link.lang,
2062
+ receivedAt: tx.link.receivedAt,
2063
+ rpChallenge: tx.acsp.rpChallenge,
2064
+ relyingPartyName: tx.acsp.relyingPartyName,
2065
+ interactions: tx.acsp.interactions
1777
2066
  });
1778
- if (opts.display) p.set("display", opts.display);
1779
- u.search = p.toString();
1780
- return { url: u.toString(), state, nonce, codeVerifier };
1781
2067
  }
1782
- /**
1783
- * redirect_uri руу ирсэн callback-ийг шийднэ: state тулгах → `error` бол {@link EidOidcError} → code солих →
1784
- * id_token шалгах. `input` — бүтэн callback URL, харьцангуй зам (`/cb?code=…`) эсвэл query (`URLSearchParams` / `code=…&state=…`); зөвхөн query-г уншина.
1785
- * Амжилтгүй бүх тохиолдолд {@link EidOidcError} (эсвэл {@link NetworkError}) шиднэ.
1786
- */
1787
- async handleCallback(input, checks) {
1788
- const q = callbackParams(input);
1789
- const state = q.get("state") ?? "";
1790
- if (!checks.state || !timingSafeEqualStr(state, checks.state)) {
1791
- throw new EidOidcError("state_mismatch", "OIDC state \u0442\u0430\u0430\u0440\u0441\u0430\u043D\u0433\u04AF\u0439 \u2014 \u043D\u044D\u0432\u0442\u0440\u044D\u043B\u0442\u0438\u0439\u0433 \u0434\u0430\u0445\u0438\u043D \u044D\u0445\u043B\u04AF\u04AF\u043B\u043D\u044D \u04AF\u04AF");
1792
- }
1793
- const iss = q.get("iss");
1794
- if (iss !== null && iss !== this.issuer) {
1795
- throw new EidOidcError("invalid_callback", "OIDC callback-\u0438\u0439\u043D iss \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439");
2068
+ async function begin(req, mode, idCode, lang) {
2069
+ const level = opts.certificateLevel ? { certificateLevel: opts.certificateLevel } : {};
2070
+ let tx;
2071
+ let appLink;
2072
+ if (mode === "push") {
2073
+ const s = await client.auth.notificationByEtsi(idCode, interactions, level);
2074
+ tx = { sessionId: s.sessionId, mode, vc: s.vc, acsp: s.acsp };
2075
+ } else {
2076
+ const callbackUrl = mode === "web2app" ? withReturnHint(opts.callbackUrl, header(req.headers, "user-agent")) : void 0;
2077
+ const s = await client.auth.deviceLinkAnonymous(interactions, { ...level, callbackUrl });
2078
+ if (!s.deviceLinkBase || !s.sessionToken || !s.sessionSecret || !s.acsp) throw new Error("eID \u0445\u0430\u0440\u0438\u0443\u043D\u0434 device link \u0442\u0430\u043B\u0431\u0430\u0440 \u0434\u0443\u0442\u0443\u0443");
2079
+ tx = {
2080
+ sessionId: s.sessionId,
2081
+ mode,
2082
+ vc: s.vc,
2083
+ acsp: s.acsp,
2084
+ link: { base: s.deviceLinkBase, token: s.sessionToken, secret: s.sessionSecret, receivedAt: s.receivedAt, lang }
2085
+ };
2086
+ if (mode === "web2app") {
2087
+ appLink = buildDeviceLink({
2088
+ deviceLinkBase: s.deviceLinkBase,
2089
+ deviceLinkType: "Web2App",
2090
+ sessionToken: s.sessionToken,
2091
+ sessionSecret: s.sessionSecret,
2092
+ sessionType: "auth",
2093
+ lang,
2094
+ rpChallenge: s.acsp.rpChallenge,
2095
+ relyingPartyName: s.acsp.relyingPartyName,
2096
+ interactions: s.acsp.interactions,
2097
+ initialCallbackUrl: s.acsp.initialCallbackUrl
2098
+ });
2099
+ }
1796
2100
  }
1797
- const err = q.get("error");
1798
- if (err) {
1799
- const code2 = KNOWN_CODES.has(err) ? err : "server_error";
1800
- throw new EidOidcError(code2, `eID \u043D\u044D\u0432\u0442\u0440\u044D\u043B\u0442 \u0430\u043C\u0436\u0438\u043B\u0442\u0433\u04AF\u0439: ${code2}`, q.get("error_description") ?? void 0);
2101
+ const binding = randomBytes2(32).toString("base64url");
2102
+ const full = { ...tx, bindingHash: sha256hex(binding), expiresAt: Date.now() + ttl };
2103
+ await store.set(full.sessionId, full, ttl);
2104
+ const setCookie = `${cookieName}=${full.sessionId}.${binding}; ${cookieAttrs(Math.ceil(ttl / 1e3))}`;
2105
+ return { tx: full, setCookie, appLink };
2106
+ }
2107
+ async function start(req) {
2108
+ const url = new URL(req.url, "http://x");
2109
+ const site = header(req.headers, "sec-fetch-site");
2110
+ if (site && site !== "same-origin" && site !== "same-site" && site !== "none") return json(403, { error: "cross_site" });
2111
+ let body = {};
2112
+ if (req.method === "GET") {
2113
+ if (url.searchParams.get("mode") !== "web2app") return json(405, { error: "method_not_allowed" });
2114
+ body = { mode: "web2app", lang: url.searchParams.get("lang") ?? void 0 };
2115
+ } else if (req.method === "POST") {
2116
+ if (!header(req.headers, "content-type").toLowerCase().startsWith("application/json")) {
2117
+ return json(415, { error: "json_required" });
2118
+ }
2119
+ try {
2120
+ const b = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
2121
+ if (b && typeof b === "object") body = b;
2122
+ } catch {
2123
+ return json(400, { error: "invalid_body" });
2124
+ }
2125
+ } else {
2126
+ return json(405, { error: "method_not_allowed" });
1801
2127
  }
1802
- const code = q.get("code") ?? "";
1803
- if (code === "" || code.length > 512) throw new EidOidcError("invalid_callback", "OIDC callback-\u0434 code \u0430\u043B\u0433\u0430");
1804
- const idToken = await this.exchangeCode(code, checks.codeVerifier);
1805
- const claims = await this.verifyIdToken(idToken, { nonce: checks.nonce });
1806
- return { claims, idToken };
1807
- }
1808
- /** `POST token_endpoint` (client_secret_basic, RFC 6749 §2.3.1) → id_token. */
1809
- async exchangeCode(code, codeVerifier) {
1810
- const d = await this.discover();
1811
- const basic = Buffer.from(formEncode(this.cfg.clientId) + ":" + formEncode(this.cfg.clientSecret)).toString("base64");
1812
- const res = await this.fetch(this.tokenEndpoint(d), {
1813
- method: "POST",
1814
- headers: {
1815
- Authorization: "Basic " + basic,
1816
- "Content-Type": "application/x-www-form-urlencoded",
1817
- Accept: "application/json"
1818
- },
1819
- body: new URLSearchParams({
1820
- grant_type: "authorization_code",
1821
- code,
1822
- redirect_uri: this.cfg.redirectUri,
1823
- code_verifier: codeVerifier
1824
- }).toString()
1825
- });
1826
- let j = {};
2128
+ const mode = body.mode;
2129
+ if (mode !== "qr" && mode !== "push" && mode !== "web2app") return json(400, { error: "invalid_mode" });
2130
+ const lang = body.lang === "en" ? "eng" : "mon";
2131
+ const ctx = { request: req, mode };
2132
+ if (mode === "web2app" && !opts.callbackUrl) return json(400, { error: "web2app_not_configured" });
2133
+ const idCode = typeof body.idCode === "string" ? body.idCode.replace(/\s+/g, "").toUpperCase() : "";
2134
+ if (mode === "push" && !ID_RE.test(idCode)) return json(400, { error: "invalid_id" });
2135
+ const limited = () => json(429, { error: "rate_limited" }, { "Retry-After": "60" });
2136
+ const ip = req.ip || (opts.trustProxy ? forwardedClientIp(req.headers, opts.trustProxy) : void 0);
2137
+ if (ip && !await rateLimit(`start:${ip}`)) return limited();
2138
+ if (mode === "push" && !await rateLimit(`push:${idCode}`)) return limited();
2139
+ let r;
1827
2140
  try {
1828
- j = await res.json();
1829
- } catch {
2141
+ r = await begin(req, mode, idCode, lang);
2142
+ } catch (e) {
2143
+ onError(e, ctx);
2144
+ const status2 = e instanceof ApiError && e.status === 429 ? 429 : 502;
2145
+ return json(status2, { error: status2 === 429 ? "rate_limited" : "eid_unavailable" });
1830
2146
  }
1831
- if (!res.ok) {
1832
- const e = typeof j.error === "string" && KNOWN_CODES.has(j.error) ? j.error : "server_error";
1833
- throw new EidOidcError(
1834
- e,
1835
- `OIDC token ${res.status}: ${e}`,
1836
- typeof j.error_description === "string" ? j.error_description : void 0
1837
- );
2147
+ if (mode === "web2app") {
2148
+ return { status: 302, headers: { Location: r.appLink, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Set-Cookie": r.setCookie }, body: "" };
1838
2149
  }
1839
- if (typeof j.id_token !== "string" || j.id_token === "") {
1840
- throw new EidOidcError("server_error", "OIDC token \u0445\u0430\u0440\u0438\u0443\u043D\u0434 id_token \u0430\u043B\u0433\u0430");
2150
+ return json(
2151
+ 200,
2152
+ { mode, vc: r.tx.vc, ...mode === "qr" ? { qr: qrLink(r.tx) } : {}, expiresIn: Math.ceil(ttl / 1e3) },
2153
+ { "Set-Cookie": r.setCookie }
2154
+ );
2155
+ }
2156
+ async function finish(req, tx, r, flow, verifier) {
2157
+ const v = client.validator.validateAuth(r, tx.acsp, { expectedFlowType: flow, userChallengeVerifier: verifier });
2158
+ if (!await store.delete(tx.sessionId)) return void 0;
2159
+ const out = await opts.onSuccess(toIdentity(tx.sessionId, r, v, flow), { request: req, mode: tx.mode }) ?? {};
2160
+ return out;
2161
+ }
2162
+ async function status(req) {
2163
+ if (req.method !== "GET") return json(405, { error: "method_not_allowed" });
2164
+ const tx = await loadTx(req);
2165
+ if (!tx) return json(200, { state: "EXPIRED" }, { "Set-Cookie": clearCookie });
2166
+ const ctx = { request: req, mode: tx.mode };
2167
+ let r;
2168
+ try {
2169
+ r = await client.session.poll(tx.sessionId, pollMs);
2170
+ } catch (e) {
2171
+ if (e instanceof ApiError && e.status === 404) {
2172
+ await store.delete(tx.sessionId);
2173
+ return json(200, { state: "EXPIRED" }, { "Set-Cookie": clearCookie });
2174
+ }
2175
+ onError(e, ctx);
2176
+ return json(502, { error: "eid_unavailable" });
1841
2177
  }
1842
- return j.id_token;
1843
- }
1844
- /** Code солих endpoint: `useMtlsEndpointAliases` + alias байвал alias (RFC 8705 §5), эс бөгөөс `token_endpoint`. */
1845
- tokenEndpoint(d) {
1846
- if (!this.cfg.useMtlsEndpointAliases) return d.token_endpoint;
1847
- const alias = d.mtls_endpoint_aliases?.token_endpoint;
1848
- if (alias === void 0) return d.token_endpoint;
1849
- if (typeof alias !== "string" || !alias.startsWith("https://")) {
1850
- throw new EidOidcError("discovery_failed", "OIDC discovery: mtls_endpoint_aliases.token_endpoint \u0431\u0443\u0440\u0443\u0443");
2178
+ const expiresIn = Math.max(0, Math.ceil((tx.expiresAt - Date.now()) / 1e3));
2179
+ if (r.state !== "COMPLETE") return json(200, { state: "PENDING", qr: qrLink(tx), vc: tx.vc, expiresIn });
2180
+ if (r.endResult !== "OK") {
2181
+ await store.delete(tx.sessionId);
2182
+ return json(200, { state: "FAILED", error: r.endResult ?? "UNKNOWN" }, { "Set-Cookie": clearCookie });
1851
2183
  }
1852
- return alias;
1853
- }
1854
- /**
1855
- * id_token шалгана: JWKS (`kid`, cache; танихгүй kid → нэг удаа дахин татна) ES256 гарын үсэг, `iss`, `aud`
1856
- * (= clientId; олон aud бол `azp`), `exp`/`iat` (±60 сек), `nonce`. Амжилтгүй бол `invalid_id_token`.
1857
- */
1858
- async verifyIdToken(idToken, opts) {
1859
- const bad = (why) => new EidOidcError("invalid_id_token", `id_token: ${why}`);
1860
- const parts = idToken.split(".");
1861
- if (parts.length !== 3) throw bad("\u0445\u044D\u043B\u0431\u044D\u0440 \u0431\u0443\u0440\u0443\u0443");
1862
- const header = decodeJsonPart(parts[0]);
1863
- const payload = decodeJsonPart(parts[1]);
1864
- if (!header || !payload) throw bad("\u0445\u044D\u043B\u0431\u044D\u0440 \u0431\u0443\u0440\u0443\u0443");
1865
- if (header.alg !== OIDC_EXPECT.idTokenAlg) throw bad("alg ES256 \u0431\u0438\u0448");
1866
- const kid = typeof header.kid === "string" ? header.kid : "";
1867
- if (!kid) throw bad("kid \u0430\u043B\u0433\u0430");
1868
- const pick = (ks) => ks.find(
1869
- (k) => k.kid === kid && k.kty === "EC" && k.crv === "P-256" && (k.use === void 0 || k.use === "sig") && (k.alg === void 0 || k.alg === "ES256")
1870
- );
1871
- const jwk = pick(await this.keys(false)) ?? pick(await this.keys(true));
1872
- if (!jwk || !jwk.x || !jwk.y) throw bad(`\u0442\u0430\u043D\u0438\u0445\u0433\u04AF\u0439 kid ${kid}`);
1873
- const sig = Buffer.from(parts[2], "base64url");
1874
- if (sig.length !== 64) throw bad("\u0433\u0430\u0440\u044B\u043D \u04AF\u0441\u044D\u0433 \u0442\u0430\u0430\u0440\u0441\u0430\u043D\u0433\u04AF\u0439");
1875
- let ok = false;
2184
+ if (tx.mode === "web2app") return json(200, { state: "WAIT_CALLBACK" });
1876
2185
  try {
1877
- const key = await globalThis.crypto.subtle.importKey(
1878
- "jwk",
1879
- { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
1880
- { name: "ECDSA", namedCurve: "P-256" },
1881
- false,
1882
- ["verify"]
1883
- );
1884
- ok = await globalThis.crypto.subtle.verify(
1885
- { name: "ECDSA", hash: "SHA-256" },
1886
- key,
1887
- sig,
1888
- new TextEncoder().encode(parts[0] + "." + parts[1])
1889
- );
1890
- } catch {
1891
- ok = false;
2186
+ const out = await finish(req, tx, r, tx.mode === "qr" ? "QR" : "Notification");
2187
+ if (!out) return json(200, { state: "EXPIRED" }, { "Set-Cookie": clearCookie });
2188
+ return json(200, { state: "OK", redirect: out.redirect, data: out.data }, mergeHeaders({ "Set-Cookie": clearCookie }, out.headers));
2189
+ } catch (e) {
2190
+ onError(e, ctx);
2191
+ if (!(e instanceof ValidationError || e instanceof SessionFailedError)) return json(500, { error: "internal" });
2192
+ await store.delete(tx.sessionId);
2193
+ return json(200, { state: "FAILED", error: "VALIDATION" }, { "Set-Cookie": clearCookie });
1892
2194
  }
1893
- if (!ok) throw bad("\u0433\u0430\u0440\u044B\u043D \u04AF\u0441\u044D\u0433 \u0442\u0430\u0430\u0440\u0441\u0430\u043D\u0433\u04AF\u0439");
1894
- const c = payload;
1895
- const now = Math.floor(Date.now() / 1e3);
1896
- if (c.iss !== this.issuer) throw bad("iss \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439");
1897
- const aud = Array.isArray(c.aud) ? c.aud : [c.aud];
1898
- if (!aud.includes(this.cfg.clientId)) throw bad("aud \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439");
1899
- if (aud.length > 1 && c.azp !== this.cfg.clientId) throw bad("azp \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439");
1900
- if (typeof c.exp !== "number" || now > c.exp + CLOCK_SKEW_S) throw bad("\u0445\u0443\u0433\u0430\u0446\u0430\u0430 \u0434\u0443\u0443\u0441\u0441\u0430\u043D");
1901
- if (typeof c.iat !== "number" || c.iat > now + CLOCK_SKEW_S) throw bad("iat \u0438\u0440\u044D\u044D\u0434\u04AF\u0439\u0434");
1902
- if (typeof c.nonce !== "string" || !opts.nonce || !timingSafeEqualStr(c.nonce, opts.nonce)) throw bad("nonce \u0442\u0430\u0430\u0440\u0430\u0445\u0433\u04AF\u0439");
1903
- if (typeof c.sub !== "string" || c.sub === "") throw bad("sub \u0430\u043B\u0433\u0430");
1904
- return c;
1905
- }
1906
- keys(force) {
1907
- const c = this.jwks;
1908
- if (!force && c && Date.now() - c.at < CACHE_MS) return c.keys;
1909
- const keys = this.discover().then(async (d) => {
1910
- const j = await this.getJson(d.jwks_uri);
1911
- return Array.isArray(j?.keys) ? j.keys : [];
1912
- });
1913
- this.jwks = { at: Date.now(), keys };
1914
- keys.catch(() => {
1915
- if (this.jwks?.keys === keys) this.jwks = void 0;
1916
- });
1917
- return keys;
1918
2195
  }
1919
- async getJson(url) {
1920
- const res = await this.fetch(url, { method: "GET", headers: { Accept: "application/json" } });
1921
- if (!res.ok) throw new EidOidcError("discovery_failed", `OIDC ${res.status}: ${url}`);
2196
+ async function callback(req) {
2197
+ if (req.method !== "GET") return json(405, { error: "method_not_allowed" });
2198
+ const q = new URL(req.url, "http://x").searchParams;
2199
+ const sid = q.get("sessionId") ?? "";
2200
+ const verifier = q.get("userChallengeVerifier") ?? "";
2201
+ const fail2 = (code) => redirect(failTo(code), { "Set-Cookie": clearCookie });
2202
+ const tx = await loadTx(req);
2203
+ if (!tx || tx.sessionId !== sid || tx.mode !== "web2app") return fail2(tx ? "unbound" : "expired");
2204
+ if (!verifier) return fail2("unbound");
2205
+ const ctx = { request: req, mode: tx.mode };
2206
+ let r;
1922
2207
  try {
1923
- return await res.json();
1924
- } catch {
1925
- throw new EidOidcError("discovery_failed", `OIDC JSON \u0431\u0438\u0448: ${url}`);
2208
+ r = await client.session.poll(tx.sessionId, 1e4);
2209
+ } catch (e) {
2210
+ onError(e, ctx);
2211
+ return fail2("eid_unavailable");
2212
+ }
2213
+ if (r.state !== "COMPLETE") return fail2("not_complete");
2214
+ if (r.endResult !== "OK") {
2215
+ await store.delete(tx.sessionId);
2216
+ return fail2(r.endResult ?? "UNKNOWN");
1926
2217
  }
1927
- }
1928
- async fetch(url, init) {
1929
- const f = this.cfg.fetchImpl ?? fetch;
1930
- const req = {
1931
- ...init,
1932
- redirect: "error",
1933
- signal: AbortSignal.timeout(this.cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
1934
- };
1935
- if (this.cfg.dispatcher) req.dispatcher = this.cfg.dispatcher;
1936
2218
  try {
1937
- return await f(url, req);
2219
+ const out = await finish(req, tx, r, "Web2App", verifier);
2220
+ if (!out) return fail2("expired");
2221
+ return redirect(out.redirect ?? successRedirect, mergeHeaders({ "Set-Cookie": clearCookie }, out.headers));
1938
2222
  } catch (e) {
1939
- const err = e;
1940
- if (err.name === "TimeoutError" || err.name === "AbortError") {
1941
- throw new NetworkError(`OIDC \u0445\u04AF\u0441\u044D\u043B\u0442 \u0445\u0443\u0433\u0430\u0446\u0430\u0430 \u0445\u044D\u0442\u044D\u0440\u043B\u044D\u044D: ${url}`);
1942
- }
1943
- throw new NetworkError(`OIDC \u0441\u04AF\u043B\u0436\u044D\u044D\u043D\u0438\u0439 \u0430\u043B\u0434\u0430\u0430 (${url}): ${err.message}`);
2223
+ onError(e, ctx);
2224
+ if (!(e instanceof ValidationError || e instanceof SessionFailedError)) return fail2("internal");
2225
+ await store.delete(tx.sessionId);
2226
+ return fail2("VALIDATION");
1944
2227
  }
1945
2228
  }
1946
- };
1947
- async function pkceChallenge(codeVerifier) {
1948
- const h = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
1949
- return Buffer.from(h).toString("base64url");
1950
- }
1951
- function randomB64url(bytes) {
1952
- return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
1953
- }
1954
- function formEncode(s) {
1955
- return new URLSearchParams({ v: s }).toString().slice(2);
1956
- }
1957
- function timingSafeEqualStr(a, b) {
1958
- const x = Buffer.from(a, "utf8");
1959
- const y = Buffer.from(b, "utf8");
1960
- return x.length === y.length && timingSafeEqual2(x, y);
1961
- }
1962
- function decodeJsonPart(s) {
1963
- try {
1964
- const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8"));
1965
- return v && typeof v === "object" && !Array.isArray(v) ? v : null;
1966
- } catch {
1967
- return null;
2229
+ async function handleRequest(req) {
2230
+ const seg = new URL(req.url, "http://x").pathname.replace(/\/+$/, "").split("/").pop();
2231
+ try {
2232
+ if (seg === "start") return await start(req);
2233
+ if (seg === "status") return await status(req);
2234
+ if (seg === "callback") return await callback(req);
2235
+ } catch (e) {
2236
+ onError(e, { request: req, mode: "qr" });
2237
+ if (e instanceof AuthenticationError || e instanceof ForbiddenError) return json(502, { error: "eid_unavailable" });
2238
+ return json(500, { error: "internal" });
2239
+ }
2240
+ return json(404, { error: "not_found" });
2241
+ }
2242
+ let warnedNoIp = false;
2243
+ async function handle(req) {
2244
+ const headers = {};
2245
+ req.headers.forEach((v, k) => headers[k] = v);
2246
+ const body = req.method === "POST" ? await req.text() : void 0;
2247
+ const ip = opts.getClientIp?.(req) || void 0;
2248
+ if (!ip && !opts.getClientIp && !opts.trustProxy && !warnedNoIp) {
2249
+ warnedNoIp = true;
2250
+ onError(new Error("eid-login: handle(Request)-\u0434 \u043A\u043B\u0438\u0435\u043D\u0442 IP \u0430\u043B\u0433\u0430 \u2014 `start:<ip>` \u0445\u044F\u0437\u0433\u0430\u0430\u0440 \u0430\u0436\u0438\u043B\u043B\u0430\u0445\u0433\u04AF\u0439; getClientIp \u044D\u0441\u0432\u044D\u043B trustProxy \u04E9\u0433"), {
2251
+ request: { method: req.method, url: req.url, headers },
2252
+ mode: "qr"
2253
+ });
2254
+ }
2255
+ const r = await handleRequest({ method: req.method, url: req.url, headers, body, ip });
2256
+ const h = new Headers();
2257
+ for (const [k, v] of Object.entries(r.headers)) for (const x of [v].flat()) h.append(k, x);
2258
+ return new Response(r.status === 302 || r.status === 303 || r.body === "" ? null : r.body, { status: r.status, headers: h });
1968
2259
  }
1969
- }
1970
- function callbackParams(input) {
1971
- if (input instanceof URLSearchParams) return input;
1972
- if (input instanceof URL) return input.searchParams;
1973
- const s = input.trim();
1974
- if (/^https?:\/\//i.test(s)) return new URL(s).searchParams;
1975
- const i = s.indexOf("?");
1976
- return new URLSearchParams(i >= 0 ? s.slice(i + 1) : s);
2260
+ return { handleRequest, handle };
1977
2261
  }
1978
2262
  export {
1979
2263
  ApiError,
@@ -1983,14 +2267,10 @@ export {
1983
2267
  DEFAULT_BASE_URL,
1984
2268
  DEFAULT_PDF_MAX_BYTES,
1985
2269
  DEFAULT_PDF_TIMEOUT_MS,
2270
+ DEFAULT_SIGN_SESSION_TTL_MS,
1986
2271
  DeviceLinkError,
1987
- EID_ACR_ADVANCED,
1988
- EID_ACR_QUALIFIED,
1989
- EID_OIDC_ISSUER,
1990
2272
  EidClient,
1991
2273
  EidError,
1992
- EidOidcClient,
1993
- EidOidcError,
1994
2274
  ForbiddenError,
1995
2275
  Http,
1996
2276
  NetworkError,
@@ -2009,23 +2289,28 @@ export {
2009
2289
  acspV2Digest,
2010
2290
  buildAcspV2Payload,
2011
2291
  buildDeviceLink,
2292
+ createEidLogin,
2012
2293
  deviceLink,
2294
+ deviceLinkInput,
2295
+ forwardedClientIp,
2013
2296
  interactionsToBase64,
2014
2297
  isMobileUserAgent,
2298
+ memoryLoginStore,
2015
2299
  openOrShowQR,
2016
2300
  parseRetryAfter,
2017
2301
  parseSessionResult,
2018
2302
  parseSubject,
2019
2303
  personMatchesCertificate,
2020
- pkceChallenge,
2021
2304
  qrDeviceLinkTicker,
2022
2305
  randomChallenge,
2023
2306
  resolveBaseUrl,
2307
+ sessionExpiry,
2024
2308
  sha256Base64,
2025
2309
  tokenAuthDigest,
2026
2310
  tokenLimitOf,
2027
2311
  userChallengeOf,
2028
2312
  verifyPayloadSignature,
2029
2313
  verifyPrehashedSignature,
2030
- verifyTokenCertificates
2314
+ verifyTokenCertificates,
2315
+ withReturnHint
2031
2316
  };