@akedly/shield
v1.1.0
Published
Client-side PoW solver, Turnstile helper, and hosted passkey ceremony launcher for Akedly Shield V1.2
Maintainers
Readme
@akedly/shield
Client-side Proof-of-Work solver, Turnstile helper, and hosted passkey ceremony launcher for Akedly Shield V1.2.
Installation
npm install @akedly/shieldOr via CDN:
<script src="https://unpkg.com/@akedly/shield/dist/akedly-shield.min.js"></script>Quick Start
import { solvePow } from '@akedly/shield';
// 1. Get challenge from your server
const res = await fetch('/api/v1.2/transactions/challenge?APIKey=...&pipelineID=...');
const { data } = await res.json();
// 2. Solve PoW
const { nonce } = await solvePow(data.challenge, data.difficulty);
// 3. Send OTP with solution
await fetch('/api/v1.2/transactions/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
APIKey: '...',
pipelineID: '...',
verificationAddress: { phoneNumber: '+201234567890' },
powSolution: { challengeToken: data.challengeToken, nonce }
})
});API Reference
solvePow(challenge, difficulty, options?)
Convenience function that creates a solver, finds the nonce, and cleans up.
challenge— 64-char hex string from the serverdifficulty— integer (number of leading hex zeros required)options.onProgress(hashesChecked)— progress callbackoptions.useWorker—true,false, or'auto'(default:'auto')- Returns
Promise<{ nonce: number }>
new AkedlyShield(options?)
Reusable solver instance.
options.useWorker—true,false, or'auto'(default:'auto')
.solve(challenge, difficulty, options?)
Same as solvePow but reuses the instance.
.terminate()
Kills the active Web Worker and releases the Blob URL.
getTurnstileToken(siteKey, options?)
Browser-only. Creates a hidden Turnstile widget, resolves with the token, and cleans up.
siteKey— Cloudflare Turnstile site keyoptions.theme—'light','dark', or'auto'options.size—'normal'or'compact'- Returns
Promise<string>
renderTurnstile(siteKey, container, options?)
Renders a Turnstile widget into a DOM element.
container— DOM element to render into- Returns
Promise<string>(token)
openPasskeyCeremony(token, options?)
Browser-only. Opens the hosted V1.2 passkey ceremony (auth.akedly.io/pk) as a popup and
resolves with the result the page relays back. Use it for both enroll (pass the
enrollmentToken from a successful /verify) and auth (pass the ceremonyToken your
backend gets from POST /auth-options).
Must be called inside the direct user-gesture handler (e.g. the click handler) or the browser will block the popup. If you need a backend round-trip to mint the token after the click (the auth case —
/auth-options), open a blank window synchronously in the handler and hand it over viaoptions.popup(see Recipe 2).
token— a ceremony token minted by your backend (enrollmentTokento enroll,ceremonyTokento authenticate)options.ceremonyOrigin— override the hosted origin (defaulthttps://auth.akedly.io)options.popup— aWindowyou opened synchronously in the click; the SDK navigates it instead of opening its own (use when the token is fetched after the gesture)options.onResult(result)— side-channel callback (same payload as the resolved value)options.onTelemetry(evt)— fire-and-forget funnel events (passkey_opened,passkey_verified,passkey_cancelled, …)options.windowFeatures/options.windowName—window.openoverridesoptions.timeoutMs— auto-resolve withreason: 'timeout'after N ms (default0= no timeout)- Returns
Promise<PasskeyCeremonyResult>:
{
verified: boolean; // true only on a completed, successful ceremony
supported: boolean; // false only when WebAuthn is unavailable
purpose: string | null; // 'enroll' | 'auth'
transactionId: string | null; // the passkey requestID
resultToken: string | null; // signed proof of a verified outcome — verify on YOUR backend
code: string | null;
reason: string | null; // null when verified; else 'closed' | 'popup_blocked'
// | 'unsupported' | 'timeout' | 'no_proof'
// | 'failed' | <server code>
// NOTE: /pk does not send a server `code` today, so `code` is
// always null and `reason` is only ever one of the local
// values above. Reserved in the contract, not a live branch.
}⚠️ Don't trust the raw
verifiedflag — verify theresultToken. The message itself is a client signal and could be forged. But every verified outcome carries aresultToken: a compact value HMAC-signed with your account API key, so your backend confirms it offline — synchronously, with no polling and no round-trip to Akedly. See Verify the result below. (Two other channels also exist: if your pipeline setsbackendCallbackURL, Akedly also fires that server-to-server callback when an authentication reaches/auth-verify— you do not opt into it, and because it is unsigned it is a delivery signal rather than proof. It does not fire for enrollment, nor for outcomes that never reach the server at all —closed,popup_blocked,unsupported,timeout— so do not wait on a webhook for those. There is also aGET /api/v1.2/transactions/passkey/result?token=<ceremonyToken>lookup — auth ceremonies only.)
The promise never rejects on a normal outcome (it resolves with a reason so you can fall
back to OTP); it rejects only on a programming error (missing token).
isPasskeySupported()
Synchronous boolean — true when the browser exposes window.PublicKeyCredential. Use it to
skip the passkey offer entirely on unsupported browsers (fall straight through to OTP).
Platform Support
| Platform | PoW Solver | Turnstile |
|----------|-----------|-----------|
| Browser (Worker) | Web Worker (off-thread) | getTurnstileToken() |
| Browser (no Worker) | Batched main-thread | getTurnstileToken() |
| React Native | Batched main-thread | Use bridge page |
| Node.js | Sync with native crypto | N/A |
Web Worker Behavior
When useWorker is 'auto' (default), the library:
- Checks if
Worker,Blob, andURL.createObjectURLare available - If yes, bundles the solver code into a Blob URL Worker (no separate file needed)
- If no (e.g., React Native), falls back to batched main-thread solving with
setTimeout(fn, 0)between batches to keep the UI responsive
Algorithm
hash = SHA256(challenge + ":" + String(nonce)) // hex digest
valid = hash.startsWith("0".repeat(difficulty)) // leading hex zerosThe solver increments nonce from 0 until a valid hash is found.
Full Integration Example
import { solvePow, getTurnstileToken } from '@akedly/shield';
async function sendOTP(phoneNumber, apiKey, pipelineID) {
// Step 1: Get challenge
const challengeRes = await fetch(
`https://api.akedly.io/api/v1.2/transactions/challenge?APIKey=${apiKey}&pipelineID=${pipelineID}`
);
const { data } = await challengeRes.json();
// Step 2: Solve PoW (if required)
let powSolution;
if (data.challengeRequired) {
const { nonce } = await solvePow(data.challenge, data.difficulty, {
onProgress: (checked) => console.log(`${checked} hashes checked...`)
});
powSolution = { challengeToken: data.challengeToken, nonce };
}
// Step 3: Get Turnstile token (if required)
let turnstileToken;
if (data.turnstile?.required) {
turnstileToken = await getTurnstileToken(data.turnstile.siteKey);
}
// Step 4: Send OTP
const sendRes = await fetch('https://api.akedly.io/api/v1.2/transactions/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
APIKey: apiKey,
pipelineID,
verificationAddress: { phoneNumber },
powSolution,
turnstileToken
})
});
return sendRes.json();
}Passkeys (V1.2)
openPasskeyCeremony is a stateless launcher — it never calls /challenge, /send, or
/verify. You own those, exactly as you do today; passkeys add three customer-driven recipes
on top. The hosted auth.akedly.io/pk page runs the WebAuthn ceremony (fully branded from your
pipeline) and relays the result back. Always confirm server-side.
Recipe 1 — Enroll a passkey right after a successful OTP verify
A V1.2 /verify success now returns an additive enrollmentToken (valid ~2 min, single
use). Offer enrollment immediately:
⚠️ Pass
returnTargetto/verifyif you want a verifiable enroll result. The hosted page relays aresultTokenonly to a server-signed return target, and the enrollment token carries one only if you asked for it. Omit it and enrollment still succeeds — the passkey is created and works — but the ceremony resolvesverified:false, reason:"no_proof", because every Akedly SDK refuses to call an unproven result verified. So do not gate your "passkey enabled!" UI onresult.verifiedunless you passedreturnTarget; treat the enroll result as advisory otherwise, and let the next successful sign-in be the proof.
import { openPasskeyCeremony, isPasskeySupported } from '@akedly/shield';
// ...your normal challenge -> (PoW/Turnstile) -> send -> verify flow...
const verify = await verifyOtp(requestID, otp); // your existing call
if (verify.data?.verified && verify.data?.enrollmentToken && isPasskeySupported()) {
// MUST run in the click handler of an "Enable Face ID / fingerprint" button.
const result = await openPasskeyCeremony(verify.data.enrollmentToken, {
onTelemetry: (e) => analytics.track(e.event, e.props)
});
if (result.verified) {
// Enrollment has no /result endpoint — the passkey now simply exists and is proven on
// the user's next sign-in. Update your own UI / record-keeping as you see fit.
await fetch('/api/passkey-enrolled', { method: 'POST', body: JSON.stringify({ transactionId: result.transactionId }) });
}
// result.verified === false (closed / unsupported / ...) => just don't show "enabled"; harmless.
}Recipe 2 — Authenticate a returning user with their passkey
Your backend starts the ceremony by calling POST /auth-options (it carries the secret API
key and clears the PoW/Turnstile gate), then hands the ceremonyToken to the browser:
⚠️ Web: include
returnTarget.originin the/auth-optionscall. The hosted page will never relay a signedresultTokento an unknown web origin — omitreturnTargetand the popup posts to"*", so the gateway strips the token (result.resultTokenarrivesnulland offline verification can't complete). Pass your site origin so the token is relayed. The enroll Enroll needs one too — corrected 2026-07-28. This line used to say "the enroll flow needs no token, and the native return-URL / bridge flows carry it without areturnTarget." Both halves were wrong. Enrollment's proof is relayed under exactly the same rule, so passreturnTargetto/verify(Recipe 1) or the enroll result arrives unproven. And of the native paths, only the WebView bridge carries the token without areturnTarget; return-URL redirects are stripped the same way on Android and Flutter.
// --- your backend ---
// POST https://api.akedly.io/api/v1.2/transactions/passkey/auth-options
// { APIKey, pipelineID, verificationAddress: { phoneNumber },
// returnTarget: { origin: 'https://your-site.example' }, // <-- REQUIRED for the web resultToken
// powSolution?, turnstileToken? }
// -> { data: { ceremonyToken, requestID } } (or a "no passkey" code => use OTP)
// --- your frontend (sign-in click handler) ---
// The token needs a backend round-trip, so open a blank popup NOW (in the gesture) and
// hand it to the SDK once the token is ready — a window.open after the await is blocked.
const popup = window.open('about:blank', 'akedly_passkey', 'width=480,height=680');
const { ceremonyToken } = await startAuth(phoneNumber); // your backend -> /auth-options
if (!ceremonyToken) { popup?.close(); return fallbackToOtp(); } // no passkey on file
const result = await openPasskeyCeremony(ceremonyToken, { popup });
if (result.verified) {
// Seamless + synchronous: hand the proof token to YOUR backend, which verifies it
// offline (no poll, no round-trip to Akedly) and creates the session. See "Verify the result".
await completeSignIn(result.resultToken);
} else {
await fallbackToOtp(); // Recipe 3
}Verify the result (seamless, no polling)
Every verified ceremony resolves a resultToken: a compact, signed proof of the
outcome. Forward it from your frontend to your backend; verify it there offline with your
account API key — no call back to Akedly, no webhook to receive.
pkrt1.<base64url(payload)>.<base64url(hmac)>
payload = { v, purpose, transactionId, pipelineId, verified, iat, exp }
hmac = HMAC_SHA256("pkrt1." + base64url(payload), YOUR_API_KEY)// --- your backend --- (Node; ~12 lines, zero deps)
import crypto from 'node:crypto';
export function verifyAkedlyResult(token, apiKey) {
if (!apiKey) return null; // fail closed on an empty key
if (typeof token !== 'string' || !token.startsWith('pkrt1.')) return null;
const parts = token.slice('pkrt1.'.length).split('.');
if (parts.length !== 2) return null; // exactly two segments
const [data, sig] = parts;
const b64u = /^[A-Za-z0-9_-]+$/; // strict unpadded base64url
if (!b64u.test(data) || !b64u.test(sig)) return null;
// canonical form only — a slack-bit alias decodes to the same bytes and would bypass
// a string-keyed single-use check
if (Buffer.from(data, 'base64url').toString('base64url') !== data) return null;
if (Buffer.from(sig, 'base64url').toString('base64url') !== sig) return null;
const expected = crypto.createHmac('sha256', apiKey).update('pkrt1.' + data).digest();
const given = Buffer.from(sig, 'base64url');
if (expected.length !== given.length || !crypto.timingSafeEqual(expected, given)) return null;
let payload;
try { payload = JSON.parse(Buffer.from(data, 'base64url').toString()); } catch (e) { return null; }
if (!payload.exp || Date.now() > payload.exp) return null; // expired
if (payload.verified !== true) return null; // only a verified outcome is trustworthy
return payload; // verified + unexpired — bind transactionId + purpose to the ceremony you started
}
// POST /complete-sign-in { resultToken }
const claim = verifyAkedlyResult(req.body.resultToken, process.env.AKEDLY_API_KEY);
if (claim?.verified && claim.purpose === 'auth' && claim.transactionId === expectedTxId) {
createSession(user); // safe: the proof is signed by us, bound to this auth transaction
}Treat the token like a one-time auth code: it is short-lived (~2 min) and you should accept it
once. The old confirmation paths still work if you prefer them (backendCallbackURL, or a
GET …/passkey/result?token= lookup).
Recipe 3 — Fall back to OTP on cancel / failure / missing proof
Every non-verified outcome is a reason you can branch on — fall straight through to your
existing OTP send + verify:
const result = await openPasskeyCeremony(token);
if (!result.verified) {
// 'closed' | 'popup_blocked' | 'unsupported' | 'timeout' | 'no_proof' | 'failed'
// (<server code> is reserved in the contract but /pk never sends one today)
await runOtpFlow(); // your existing challenge -> send -> verify
}A working end-to-end demo (Node backend driving the real V1.2 REST + a React front end that
enrolls, authenticates, and falls back) lives in
akedly-v12-test-apps/web — these recipes are extracted from it.
Related Packages
- Dart/Flutter:
akedly_shield - Swift (iOS):
AkedlyShield - Kotlin (Android):
com.akedly.shield
