@classytic/primitives 0.9.1 → 0.11.0
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/CHANGELOG.md +19 -0
- package/dist/identity-verification.d.mts +113 -0
- package/dist/identity-verification.mjs +30 -0
- package/dist/otp.d.mts +96 -0
- package/dist/otp.mjs +132 -0
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.11.0] - 2026-07-08
|
|
7
|
+
|
|
8
|
+
### Added — eKYC vocabulary + secure OTP primitive
|
|
9
|
+
|
|
10
|
+
- **`@classytic/primitives/identity-verification`** — cross-package eKYC data shapes:
|
|
11
|
+
`IdentityDocumentType`, `VerificationCheck`, `VerificationLevel`, `SubjectKind`,
|
|
12
|
+
`IdentitySubject`, `DocumentReference`, `IdentityProviderCapabilities`,
|
|
13
|
+
`VerificationStatus`, `CheckResult`, `CreateVerificationParams`,
|
|
14
|
+
`VerificationSession`, `VerificationWebhookEvent`. Pure helpers: `isVerified`,
|
|
15
|
+
`checksSatisfy`, `deriveStatus`. The identity twin of `payment-gateway` — shapes
|
|
16
|
+
here, contracts in engines, provider implementations anywhere. Serves `@classytic/esign`
|
|
17
|
+
(IdentityBridge port), `@classytic/payee` (ComplianceBridge), and PSP merchant KYC.
|
|
18
|
+
- **`@classytic/primitives/otp`** — secure OTP challenge lifecycle. Guarantees: code
|
|
19
|
+
never persisted (only HMAC-SHA256 digest stored), constant-time `timingSafeEqual`
|
|
20
|
+
comparison, rejection-free `randomInt` CSPRNG (no modulo bias), bounded attempt
|
|
21
|
+
counter, TTL expiry, single-use verified state. Exports: `createOtpChallenge`,
|
|
22
|
+
`verifyOtpChallenge`, `isOtpVerified`, `isOtpActive`. Delivery stays in
|
|
23
|
+
`@classytic/notifications` — this primitive owns only the verify half.
|
|
24
|
+
|
|
6
25
|
## [0.9.1] - 2026-07-04
|
|
7
26
|
|
|
8
27
|
### Added
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { PersonName } from "./person.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/identity/identity-verification.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Government / institutional identity documents. `trade_license` and
|
|
6
|
+
* `tin_certificate` support MERCHANT (business) onboarding — a payment gateway
|
|
7
|
+
* KYBs the business, not just a person.
|
|
8
|
+
*/
|
|
9
|
+
type IdentityDocumentType = 'nid' | 'passport' | 'driving_license' | 'birth_certificate' | 'residence_permit' | 'trade_license' | 'tin_certificate' | 'other';
|
|
10
|
+
/** A single verification check a session can run. Providers advertise support. */
|
|
11
|
+
type VerificationCheck = 'document' | 'liveness' | 'face_match' | 'database' | 'address' | 'sanctions' | 'pep' | 'adverse_media';
|
|
12
|
+
/**
|
|
13
|
+
* Assurance tier — mirrors the market (Stripe Identity / Persona levels).
|
|
14
|
+
* Higher tiers imply a superset of checks; the exact mapping is host policy.
|
|
15
|
+
*/
|
|
16
|
+
type VerificationLevel = 'basic' | 'standard' | 'enhanced';
|
|
17
|
+
/** The kind of subject under verification — a natural person or a business. */
|
|
18
|
+
type SubjectKind = 'individual' | 'business';
|
|
19
|
+
interface DocumentReference {
|
|
20
|
+
readonly type: IdentityDocumentType;
|
|
21
|
+
/** Document number (NID/passport no.). May be redacted/tokenized at rest. */
|
|
22
|
+
readonly number?: string;
|
|
23
|
+
/** ISO 3166-1 alpha-2 issuing country. */
|
|
24
|
+
readonly country?: string;
|
|
25
|
+
/** ISO date (YYYY-MM-DD). */
|
|
26
|
+
readonly expiresOn?: string;
|
|
27
|
+
}
|
|
28
|
+
interface IdentitySubject {
|
|
29
|
+
readonly kind: SubjectKind;
|
|
30
|
+
readonly name?: PersonName;
|
|
31
|
+
/** Legal/business name when `kind === 'business'`. */
|
|
32
|
+
readonly legalName?: string;
|
|
33
|
+
/** ISO date (YYYY-MM-DD). */
|
|
34
|
+
readonly dateOfBirth?: string;
|
|
35
|
+
/** ISO 3166-1 alpha-2. */
|
|
36
|
+
readonly country?: string;
|
|
37
|
+
}
|
|
38
|
+
interface IdentityProviderCapabilities {
|
|
39
|
+
readonly checks: readonly VerificationCheck[];
|
|
40
|
+
readonly documents: readonly IdentityDocumentType[];
|
|
41
|
+
/** ISO 3166-1 alpha-2 codes; empty ⇒ global. */
|
|
42
|
+
readonly countries: readonly string[];
|
|
43
|
+
/** Delivers async results via webhook (vs poll-only). */
|
|
44
|
+
readonly webhooks: boolean;
|
|
45
|
+
/** Supports business/KYB flows, not just individuals. */
|
|
46
|
+
readonly business: boolean;
|
|
47
|
+
}
|
|
48
|
+
type VerificationStatus = 'pending' | 'processing' | 'verified' | 'rejected' | 'review' | 'expired';
|
|
49
|
+
type CheckOutcome = 'pass' | 'fail' | 'manual' | 'skipped';
|
|
50
|
+
interface CheckResult {
|
|
51
|
+
readonly check: VerificationCheck;
|
|
52
|
+
readonly outcome: CheckOutcome;
|
|
53
|
+
/** Provider-specific reason/code for a fail/manual outcome. */
|
|
54
|
+
readonly reason?: string;
|
|
55
|
+
}
|
|
56
|
+
interface CreateVerificationParams {
|
|
57
|
+
readonly subject: IdentitySubject;
|
|
58
|
+
readonly level: VerificationLevel;
|
|
59
|
+
/** Checks the host requires to consider the subject verified. */
|
|
60
|
+
readonly requiredChecks?: readonly VerificationCheck[];
|
|
61
|
+
readonly documents?: readonly DocumentReference[];
|
|
62
|
+
/** Where the provider redirects the subject after a hosted flow. */
|
|
63
|
+
readonly redirectUrl?: string;
|
|
64
|
+
/** Correlates the session back to a host entity (payee id, signer id, …). */
|
|
65
|
+
readonly reference?: string;
|
|
66
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
67
|
+
}
|
|
68
|
+
interface VerificationSession {
|
|
69
|
+
/** Provider-side session id — the handle a host stores and polls. */
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly provider: string;
|
|
72
|
+
readonly level: VerificationLevel;
|
|
73
|
+
readonly status: VerificationStatus;
|
|
74
|
+
readonly checks: readonly CheckResult[];
|
|
75
|
+
/** Hosted-flow URL for the subject, when the provider uses one. */
|
|
76
|
+
readonly url?: string;
|
|
77
|
+
readonly reference?: string;
|
|
78
|
+
/** ISO timestamp the verification was decided. */
|
|
79
|
+
readonly decidedAt?: string;
|
|
80
|
+
/**
|
|
81
|
+
* ISO timestamp the verification lapses and re-KYC is required. Periodic
|
|
82
|
+
* re-KYC is an AML requirement; consumers gate on this (payee pauses
|
|
83
|
+
* payability, esign re-verifies before signing).
|
|
84
|
+
*/
|
|
85
|
+
readonly expiresAt?: string;
|
|
86
|
+
/** Raw provider payload for audit/debugging. */
|
|
87
|
+
readonly raw?: unknown;
|
|
88
|
+
}
|
|
89
|
+
/** Normalized provider webhook — the async-decision callback shape. */
|
|
90
|
+
interface VerificationWebhookEvent {
|
|
91
|
+
readonly sessionId: string;
|
|
92
|
+
readonly provider: string;
|
|
93
|
+
readonly status: VerificationStatus;
|
|
94
|
+
readonly checks?: readonly CheckResult[];
|
|
95
|
+
readonly occurredAt: string;
|
|
96
|
+
readonly raw?: unknown;
|
|
97
|
+
}
|
|
98
|
+
/** A session is usable when it passed and has not lapsed at `now`. */
|
|
99
|
+
declare function isVerified(session: VerificationSession, now?: Date): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Do the check RESULTS satisfy the REQUIRED checks? Every required check must
|
|
102
|
+
* have a `pass`. Missing or non-pass required checks ⇒ false. Extra results
|
|
103
|
+
* are ignored.
|
|
104
|
+
*/
|
|
105
|
+
declare function checksSatisfy(required: readonly VerificationCheck[], results: readonly CheckResult[]): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Derive a session status from check results alone (for providers that return
|
|
108
|
+
* raw checks without a verdict): any `fail` ⇒ rejected; any `manual` ⇒ review;
|
|
109
|
+
* all required present and `pass` ⇒ verified; otherwise processing.
|
|
110
|
+
*/
|
|
111
|
+
declare function deriveStatus(results: readonly CheckResult[], required?: readonly VerificationCheck[]): VerificationStatus;
|
|
112
|
+
//#endregion
|
|
113
|
+
export { CheckOutcome, CheckResult, CreateVerificationParams, DocumentReference, IdentityDocumentType, IdentityProviderCapabilities, IdentitySubject, SubjectKind, VerificationCheck, VerificationLevel, VerificationSession, VerificationStatus, VerificationWebhookEvent, checksSatisfy, deriveStatus, isVerified };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/identity/identity-verification.ts
|
|
2
|
+
/** A session is usable when it passed and has not lapsed at `now`. */
|
|
3
|
+
function isVerified(session, now) {
|
|
4
|
+
if (session.status !== "verified") return false;
|
|
5
|
+
if (!session.expiresAt) return true;
|
|
6
|
+
const at = (now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
7
|
+
return session.expiresAt > at;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Do the check RESULTS satisfy the REQUIRED checks? Every required check must
|
|
11
|
+
* have a `pass`. Missing or non-pass required checks ⇒ false. Extra results
|
|
12
|
+
* are ignored.
|
|
13
|
+
*/
|
|
14
|
+
function checksSatisfy(required, results) {
|
|
15
|
+
const passed = new Set(results.filter((r) => r.outcome === "pass").map((r) => r.check));
|
|
16
|
+
return required.every((c) => passed.has(c));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Derive a session status from check results alone (for providers that return
|
|
20
|
+
* raw checks without a verdict): any `fail` ⇒ rejected; any `manual` ⇒ review;
|
|
21
|
+
* all required present and `pass` ⇒ verified; otherwise processing.
|
|
22
|
+
*/
|
|
23
|
+
function deriveStatus(results, required = []) {
|
|
24
|
+
if (results.some((r) => r.outcome === "fail")) return "rejected";
|
|
25
|
+
if (results.some((r) => r.outcome === "manual")) return "review";
|
|
26
|
+
if (required.length > 0 && checksSatisfy(required, results)) return "verified";
|
|
27
|
+
return "processing";
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { checksSatisfy, deriveStatus, isVerified };
|
package/dist/otp.d.mts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region src/identity/otp.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* OTP challenge — the pure, secure *verify* half of one-time-code verification.
|
|
4
|
+
*
|
|
5
|
+
* This primitive owns SECURITY, never delivery. Delivery (SMS / email / push /
|
|
6
|
+
* WhatsApp, templates, retry, fallback) is `@classytic/notifications`; the host
|
|
7
|
+
* passes the returned `code` to whichever channel/provider it likes. This file
|
|
8
|
+
* only guarantees the code cannot be forged, replayed, brute-forced, or leaked:
|
|
9
|
+
*
|
|
10
|
+
* - the plaintext code is NEVER persisted — only its HMAC-SHA256 digest is
|
|
11
|
+
* (a stolen challenge row cannot reveal the code);
|
|
12
|
+
* - comparison is constant-time (`timingSafeEqual`) — no timing oracle;
|
|
13
|
+
* - the code is drawn from a CSPRNG with rejection-free `randomInt` — no
|
|
14
|
+
* modulo bias;
|
|
15
|
+
* - a bounded attempt counter locks the challenge — no online brute force;
|
|
16
|
+
* - a TTL expires the challenge — no indefinite window;
|
|
17
|
+
* - a verified challenge is single-use — no replay.
|
|
18
|
+
*
|
|
19
|
+
* The shape is a plain value object: create it, persist `challenge`, and on each
|
|
20
|
+
* attempt feed the stored challenge back through `verifyOtpChallenge` and persist
|
|
21
|
+
* the returned next state. No I/O, no clock capture beyond an injectable `now`.
|
|
22
|
+
*
|
|
23
|
+
* Resend throttling (don't mint a new code too soon) is a host / arc-layer
|
|
24
|
+
* concern — rate-limit calls to `createOtpChallenge`; it is deliberately not
|
|
25
|
+
* modeled here so the verify primitive stays stateless between attempts.
|
|
26
|
+
*/
|
|
27
|
+
/** Terminal + in-flight states a persisted challenge can hold. */
|
|
28
|
+
type OtpStatus = 'pending' | 'verified' | 'failed' | 'expired';
|
|
29
|
+
/** The result of a single `verifyOtpChallenge` call. */
|
|
30
|
+
type OtpOutcome = 'verified' | 'incorrect' | 'expired' | 'locked' | 'already_used';
|
|
31
|
+
/**
|
|
32
|
+
* The persistable challenge. Contains no secret material: `hash` is an HMAC of
|
|
33
|
+
* the code under a server-side `secret`, so the row is useless to a thief who
|
|
34
|
+
* lacks the secret. Persist this verbatim; never add the plaintext code to it.
|
|
35
|
+
*/
|
|
36
|
+
interface OtpChallenge {
|
|
37
|
+
/** HMAC-SHA256(code, secret) as hex. The plaintext code is never stored. */
|
|
38
|
+
readonly hash: string;
|
|
39
|
+
/** ISO-8601 instant after which the challenge is dead. */
|
|
40
|
+
readonly expiresAt: string;
|
|
41
|
+
/** Failed attempts so far. */
|
|
42
|
+
readonly attempts: number;
|
|
43
|
+
/** Attempts allowed before the challenge locks (`failed`). */
|
|
44
|
+
readonly maxAttempts: number;
|
|
45
|
+
/** Lifecycle state. */
|
|
46
|
+
readonly status: OtpStatus;
|
|
47
|
+
}
|
|
48
|
+
interface CreateOtpOptions {
|
|
49
|
+
/** Server-side HMAC key. Required — a challenge with no secret is forgeable. */
|
|
50
|
+
readonly secret: string;
|
|
51
|
+
/** Number of digits in the code. Default 6. */
|
|
52
|
+
readonly length?: number;
|
|
53
|
+
/** Time-to-live in ms. Default 5 minutes. */
|
|
54
|
+
readonly ttlMs?: number;
|
|
55
|
+
/** Failed attempts before lock. Default 5. */
|
|
56
|
+
readonly maxAttempts?: number;
|
|
57
|
+
/** Injectable clock for deterministic tests. Default `new Date()`. */
|
|
58
|
+
readonly now?: Date;
|
|
59
|
+
}
|
|
60
|
+
interface CreatedOtp {
|
|
61
|
+
/** Persist this. Contains only the HMAC digest, never the code. */
|
|
62
|
+
readonly challenge: OtpChallenge;
|
|
63
|
+
/** Deliver this via `@classytic/notifications`. NEVER persist it. */
|
|
64
|
+
readonly code: string;
|
|
65
|
+
}
|
|
66
|
+
interface VerifyOtpOptions {
|
|
67
|
+
/** The same server-side HMAC key used at creation. */
|
|
68
|
+
readonly secret: string;
|
|
69
|
+
/** Injectable clock for deterministic tests. Default `new Date()`. */
|
|
70
|
+
readonly now?: Date;
|
|
71
|
+
}
|
|
72
|
+
interface OtpVerification {
|
|
73
|
+
/** The next challenge state to persist (attempt count / status advanced). */
|
|
74
|
+
readonly challenge: OtpChallenge;
|
|
75
|
+
/** What happened on this attempt. */
|
|
76
|
+
readonly outcome: OtpOutcome;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Mint a challenge + its one-time code. Persist `challenge`; hand `code` to the
|
|
80
|
+
* delivery layer. The code exists only in this return value and in the delivered
|
|
81
|
+
* message — it is never recoverable from the persisted challenge.
|
|
82
|
+
*/
|
|
83
|
+
declare function createOtpChallenge(options: CreateOtpOptions): CreatedOtp;
|
|
84
|
+
/**
|
|
85
|
+
* Check `input` against a persisted challenge and return the next state. This is
|
|
86
|
+
* a pure transition — the caller persists `result.challenge`. Fail-closed: any
|
|
87
|
+
* non-`pending` challenge, an expired window, or an over-limit attempt yields a
|
|
88
|
+
* non-`verified` outcome and never re-opens a settled challenge.
|
|
89
|
+
*/
|
|
90
|
+
declare function verifyOtpChallenge(challenge: OtpChallenge, input: string, options: VerifyOtpOptions): OtpVerification;
|
|
91
|
+
/** True once a challenge has been successfully verified. */
|
|
92
|
+
declare function isOtpVerified(challenge: OtpChallenge): boolean;
|
|
93
|
+
/** True while a challenge can still accept an attempt (pending + unexpired). */
|
|
94
|
+
declare function isOtpActive(challenge: OtpChallenge, now?: Date): boolean;
|
|
95
|
+
//#endregion
|
|
96
|
+
export { CreateOtpOptions, CreatedOtp, OtpChallenge, OtpOutcome, OtpStatus, OtpVerification, VerifyOtpOptions, createOtpChallenge, isOtpActive, isOtpVerified, verifyOtpChallenge };
|
package/dist/otp.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHmac, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
//#region src/identity/otp.ts
|
|
3
|
+
/**
|
|
4
|
+
* OTP challenge — the pure, secure *verify* half of one-time-code verification.
|
|
5
|
+
*
|
|
6
|
+
* This primitive owns SECURITY, never delivery. Delivery (SMS / email / push /
|
|
7
|
+
* WhatsApp, templates, retry, fallback) is `@classytic/notifications`; the host
|
|
8
|
+
* passes the returned `code` to whichever channel/provider it likes. This file
|
|
9
|
+
* only guarantees the code cannot be forged, replayed, brute-forced, or leaked:
|
|
10
|
+
*
|
|
11
|
+
* - the plaintext code is NEVER persisted — only its HMAC-SHA256 digest is
|
|
12
|
+
* (a stolen challenge row cannot reveal the code);
|
|
13
|
+
* - comparison is constant-time (`timingSafeEqual`) — no timing oracle;
|
|
14
|
+
* - the code is drawn from a CSPRNG with rejection-free `randomInt` — no
|
|
15
|
+
* modulo bias;
|
|
16
|
+
* - a bounded attempt counter locks the challenge — no online brute force;
|
|
17
|
+
* - a TTL expires the challenge — no indefinite window;
|
|
18
|
+
* - a verified challenge is single-use — no replay.
|
|
19
|
+
*
|
|
20
|
+
* The shape is a plain value object: create it, persist `challenge`, and on each
|
|
21
|
+
* attempt feed the stored challenge back through `verifyOtpChallenge` and persist
|
|
22
|
+
* the returned next state. No I/O, no clock capture beyond an injectable `now`.
|
|
23
|
+
*
|
|
24
|
+
* Resend throttling (don't mint a new code too soon) is a host / arc-layer
|
|
25
|
+
* concern — rate-limit calls to `createOtpChallenge`; it is deliberately not
|
|
26
|
+
* modeled here so the verify primitive stays stateless between attempts.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_LENGTH = 6;
|
|
29
|
+
const DEFAULT_TTL_MS = 300 * 1e3;
|
|
30
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
31
|
+
function hmac(code, secret) {
|
|
32
|
+
return createHmac("sha256", secret).update(code).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
/** CSPRNG numeric code with no modulo bias (`randomInt` rejection-samples). */
|
|
35
|
+
function generateCode(length) {
|
|
36
|
+
let code = "";
|
|
37
|
+
for (let i = 0; i < length; i += 1) code += randomInt(0, 10).toString();
|
|
38
|
+
return code;
|
|
39
|
+
}
|
|
40
|
+
/** Constant-time compare of two equal-length hex digests. */
|
|
41
|
+
function constantTimeEqualHex(a, b) {
|
|
42
|
+
const ab = Buffer.from(a, "hex");
|
|
43
|
+
const bb = Buffer.from(b, "hex");
|
|
44
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Mint a challenge + its one-time code. Persist `challenge`; hand `code` to the
|
|
48
|
+
* delivery layer. The code exists only in this return value and in the delivered
|
|
49
|
+
* message — it is never recoverable from the persisted challenge.
|
|
50
|
+
*/
|
|
51
|
+
function createOtpChallenge(options) {
|
|
52
|
+
if (!options.secret) throw new Error("[otp] a `secret` is required to HMAC the code.");
|
|
53
|
+
const length = options.length ?? DEFAULT_LENGTH;
|
|
54
|
+
if (length < 4) throw new Error("[otp] `length` must be at least 4 digits.");
|
|
55
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
56
|
+
if (ttlMs <= 0) throw new Error("[otp] `ttlMs` must be positive.");
|
|
57
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
58
|
+
if (maxAttempts < 1) throw new Error("[otp] `maxAttempts` must be at least 1.");
|
|
59
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
60
|
+
const code = generateCode(length);
|
|
61
|
+
return {
|
|
62
|
+
code,
|
|
63
|
+
challenge: {
|
|
64
|
+
hash: hmac(code, options.secret),
|
|
65
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
66
|
+
attempts: 0,
|
|
67
|
+
maxAttempts,
|
|
68
|
+
status: "pending"
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Check `input` against a persisted challenge and return the next state. This is
|
|
74
|
+
* a pure transition — the caller persists `result.challenge`. Fail-closed: any
|
|
75
|
+
* non-`pending` challenge, an expired window, or an over-limit attempt yields a
|
|
76
|
+
* non-`verified` outcome and never re-opens a settled challenge.
|
|
77
|
+
*/
|
|
78
|
+
function verifyOtpChallenge(challenge, input, options) {
|
|
79
|
+
if (!options.secret) throw new Error("[otp] a `secret` is required to verify the code.");
|
|
80
|
+
if (challenge.status === "verified") return {
|
|
81
|
+
challenge,
|
|
82
|
+
outcome: "already_used"
|
|
83
|
+
};
|
|
84
|
+
if (challenge.status === "failed") return {
|
|
85
|
+
challenge,
|
|
86
|
+
outcome: "locked"
|
|
87
|
+
};
|
|
88
|
+
if (challenge.status === "expired") return {
|
|
89
|
+
challenge,
|
|
90
|
+
outcome: "expired"
|
|
91
|
+
};
|
|
92
|
+
if ((options.now ?? /* @__PURE__ */ new Date()).getTime() > Date.parse(challenge.expiresAt)) return {
|
|
93
|
+
challenge: {
|
|
94
|
+
...challenge,
|
|
95
|
+
status: "expired"
|
|
96
|
+
},
|
|
97
|
+
outcome: "expired"
|
|
98
|
+
};
|
|
99
|
+
if (constantTimeEqualHex(hmac(input, options.secret), challenge.hash)) return {
|
|
100
|
+
challenge: {
|
|
101
|
+
...challenge,
|
|
102
|
+
status: "verified"
|
|
103
|
+
},
|
|
104
|
+
outcome: "verified"
|
|
105
|
+
};
|
|
106
|
+
const attempts = challenge.attempts + 1;
|
|
107
|
+
if (attempts >= challenge.maxAttempts) return {
|
|
108
|
+
challenge: {
|
|
109
|
+
...challenge,
|
|
110
|
+
attempts,
|
|
111
|
+
status: "failed"
|
|
112
|
+
},
|
|
113
|
+
outcome: "locked"
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
challenge: {
|
|
117
|
+
...challenge,
|
|
118
|
+
attempts
|
|
119
|
+
},
|
|
120
|
+
outcome: "incorrect"
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** True once a challenge has been successfully verified. */
|
|
124
|
+
function isOtpVerified(challenge) {
|
|
125
|
+
return challenge.status === "verified";
|
|
126
|
+
}
|
|
127
|
+
/** True while a challenge can still accept an attempt (pending + unexpired). */
|
|
128
|
+
function isOtpActive(challenge, now = /* @__PURE__ */ new Date()) {
|
|
129
|
+
return challenge.status === "pending" && now.getTime() <= Date.parse(challenge.expiresAt);
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
export { createOtpChallenge, isOtpActive, isOtpVerified, verifyOtpChallenge };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|
|
@@ -97,6 +97,14 @@
|
|
|
97
97
|
"types": "./dist/person.d.mts",
|
|
98
98
|
"default": "./dist/person.mjs"
|
|
99
99
|
},
|
|
100
|
+
"./identity-verification": {
|
|
101
|
+
"types": "./dist/identity-verification.d.mts",
|
|
102
|
+
"default": "./dist/identity-verification.mjs"
|
|
103
|
+
},
|
|
104
|
+
"./otp": {
|
|
105
|
+
"types": "./dist/otp.d.mts",
|
|
106
|
+
"default": "./dist/otp.mjs"
|
|
107
|
+
},
|
|
100
108
|
"./state-machine": {
|
|
101
109
|
"types": "./dist/state-machine.d.mts",
|
|
102
110
|
"default": "./dist/state-machine.mjs"
|