@clientsdemo/mfa-client
v0.1.0
Published
Server-side client for the clientsdemo MFA push-authentication service — create enrollments, dispatch sign-in requests, verify number matching, and consume webhooks.
Maintainers
Readme
@clientsdemo/mfa-client
Server-side client for the clientsdemo MFA push-authentication service. Your web application uses it to enroll users' phones, push sign-in approval requests, and verify number matching — the phone side is handled by the MFA Authenticator Android app.
Zero dependencies; runs on Node 18+, Cloudflare Workers, and edge runtimes.
Status: the service (Cloudflare Worker + D1) is live in production at
https://mfa.clientsdemo.site; this package is the supported way to talk to it. The full API contract lives indocs/API.mdin the repo.
Install
npm install @clientsdemo/mfa-clientSetup
import { MfaServerClient } from '@clientsdemo/mfa-client';
const mfa = new MfaServerClient({
serverUrl: process.env.MFA_SERVER_URL!, // https://mfa.clientsdemo.site
appId: 'ehs', // your registered app id
apiKey: process.env.MFA_API_KEY!, // server-to-server key; never ship to browsers
});Enroll a device (account security settings page)
const enrollment = await mfa.createEnrollment({ userId: user.id, userEmail: user.email });
// Render enrollment.setupUri as a QR code AND as copyable text.
// The user scans it with the MFA Authenticator app. Codes are single-use and expire.Manage devices:
const devices = await mfa.listEnrollments(user.id);
await mfa.revokeEnrollment(deviceToRemove.enrollmentId);Protect a login (number matching)
// After the first factor (password/SSO) succeeds:
const req = await mfa.createRequest({
userId: user.id,
context: { ip: clientIp, userAgent, locationHint: 'Bengaluru, IN' },
});
// The user's phone shows a verification number. The user types it into your
// login form and taps Approve on the phone.
const numberOk = await mfa.verifyNumber(req.requestId, submittedNumber);
if (!numberOk.valid) return reject('Wrong verification number');
const decision = await mfa.waitForDecision(req.requestId, { timeoutMs: 120_000 });
if (decision.status !== 'approved') return reject(`MFA ${decision.status}`);
// Both factors passed — create the session.Treat anything other than approved + valid: true as a failed login. Requests expire server-side after 5 minutes; denials and expiries are final.
Second-factor login bridge (stateless flows)
Push approval happens on the phone, out-of-band, while a web login is stateless across requests. createSecondFactorBridge() carries the approval between steps with two short-lived HMAC tokens, so you don't hand-roll it per app:
import { createSecondFactorBridge } from '@clientsdemo/mfa-client';
const bridge = createSecondFactorBridge(process.env.AUTH_SECRET!); // reuse your app secret
// 1) After the password check, create the request and issue a challenge:
const req = await mfa.createRequest({ userId, context });
const challenge = await bridge.mintChallenge(email, req.requestId); // send to the browser
// 2) Verify endpoint — the user typed the number from their phone:
const requestId = await bridge.verifyChallenge(challenge, email); // null if invalid/expired
if (!requestId) throw new Error('bad challenge');
if (!(await mfa.verifyNumber(requestId, typedNumber)).valid) throw new Error('wrong number');
if ((await mfa.waitForDecision(requestId)).status !== 'approved') throw new Error('not approved');
const proof = await bridge.mintProof(userId); // hand back to the login step
// 3) Session-mint step (e.g. NextAuth authorize) — accept only a valid proof:
if (!(await bridge.verifyProof(proof, userId))) return null;Challenge default TTL 5 min (the request lifetime), proof 2 min. Tokens are HMAC-SHA256, tamper-evident, and kind-isolated (a proof can't be replayed as a challenge).
Webhooks (optional, instead of polling)
Register a webhook URL for your app (via the service admin API). Deliveries are HMAC-SHA256 signed (X-Webhook-Signature: sha256=<hex> over the raw body):
const event = await MfaServerClient.verifyWebhook(rawBody, signatureHeader, process.env.MFA_WEBHOOK_SECRET!);
if (!event) return new Response('bad signature', { status: 401 });
// event.type ∈ request.approved | request.denied | request.expired
// | enrollment.completed | enrollment.revoked | enrollment.recovered
// | security.request_reported — user tapped "this wasn't me"
// | security.push_fatigue_suspected — spray blocked; consider locking the login
// | security.additional_device_enrolled — a 2nd+ device was added; warn the userAct on the security.* events — they are the service telling you a login is likely under attack.
Required environment variables
| Variable | Purpose |
|---|---|
| MFA_SERVER_URL | MFA service base URL |
| MFA_API_KEY | Server-to-server API key for your app |
| MFA_WEBHOOK_SECRET | Only if you consume webhooks |
Security notes
- The verification number is generated by the service and delivered only to the enrolled device — your app never sees it;
verifyNumber()is a server-side comparison and is rate-limited. - Device approvals are signed on-device with a hardware-backed key; the service verifies that signature before a request can become
approved. - Always pass real
context— the user sees it on the approval screen, and it is what lets them spot a phishing attempt.
License
MIT
