@solidus-network/auth-otp
A pure OTP (one-time code) login core. It generates, stores, and verifies 6-digit codes, then hands delivery and identity resolution back to you through two small interfaces.
It has zero runtime dependencies and does nothing useful on its own — by design. You bring your own sender and your own session issuer.
npm install @solidus-network/auth-otpimport { startLogin, completeLogin, memoryOtpStore } from '@solidus-network/auth-otp'What it deliberately does not do
Read this before designing around it — these are refusals, not gaps to be filled in a later patch.
| It does not | Because |
|---|---|
| Send anything. No Twilio, Vonage, Resend, or SES import — ever. | Delivery receipts, sender IDs, and regional routing belong to your adapter. You implement OtpSender. |
| Resolve or mint identity. | Minting a did:solidus for a first-time phone number means generating and custodying a keypair. This package refuses to make that decision for you — you implement resolvePrincipal. |
Issue a session. completeLogin stops at { did, isNew }. | Sign your own session token with the signer your backend already has. |
No SMS sender ships in 0.1.0. The
channelparameter accepts'sms', and the core is channel-agnostic — but this package contains no real SMS adapter. The email channel is fully usable today through anyOtpSenderyou wire in. A production SMS adapter needs a provider account and a test handset, so it is a separate piece of work, not a missing import.
startLogin
Generates a code, stores it, and hands it to your sender. Returns nothing — the code is never part of the return value or any log this function touches.
startLogin(
deps: {
store: OtpStore
sender: OtpSender
now: number
rng?: () => number
expiresInMs?: number
},
args: { channel: 'sms' | 'email'; to: string }
): Promise<void>Parameters
| Name | Type | Default | Description |
|---|---|---|---|
deps.store | OtpStore | — | Where the hashed code is held |
deps.sender | OtpSender | — | Your delivery adapter |
deps.now | number | — | Current instant, injected — the package never calls Date.now() |
deps.rng | () => number | Math.random | Must return a float in [0, 1). Inject a CSPRNG in production |
deps.expiresInMs | number | — | Code lifetime |
args.channel | 'sms' | 'email' | — | Passed through to your sender |
args.to | string | — | Phone number or email — also the store key |
An error thrown by sender.send propagates as a rejection.
Example
import { startLogin, memoryOtpStore } from '@solidus-network/auth-otp'
import { webcrypto } from 'node:crypto'
const store = memoryOtpStore()
// Your delivery adapter — the only place a real code appears.
const sender = {
async send(channel: 'sms' | 'email', to: string, code: string) {
await resend.emails.send({
from: '[email protected]',
to,
subject: 'Your code',
text: `Your code is ${code}. It expires in 5 minutes.`,
})
},
}
// A CSPRNG, not Math.random.
const rng = () => webcrypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32
await startLogin(
{ store, sender, now: Date.now(), rng, expiresInMs: 5 * 60_000 },
{ channel: 'email', to: '[email protected]' },
)completeLogin
Verifies the code and — only on success — calls your resolvePrincipal to find or mint the DID behind to.
completeLogin(
deps: {
store: OtpStore
resolvePrincipal: (to: string) => Promise<{ did: string; isNew: boolean }>
now: number
maxAttempts?: number
},
args: { to: string; code: string }
): Promise<{ did: string; isNew: boolean } | { error: 'expired' | 'wrong' | 'locked' | 'none' }>Discriminate the result by checking for error — there is no ok flag.
Example
const result = await completeLogin(
{
store,
now: Date.now(),
maxAttempts: 5,
async resolvePrincipal(to) {
const existing = await db.users.findByEmail(to)
if (existing) return { did: existing.did, isNew: false }
const did = await mintDidWithYourOwnCustodyModel(to)
return { did, isNew: true }
},
},
{ to: '[email protected]', code: '481920' },
)
if ('error' in result) {
return reply.code(401).send({ error: result.error })
}
// auth-otp stops here. Signing the session is your job.
const token = await signSession({ sub: result.did })verifyOtp
The lower-level check, if you are not using completeLogin.
verifyOtp(
store: OtpStore,
args: { to: string; code: string; now: number; maxAttempts: number }
): Promise<VerifyOtpResult>VerifyOtpResult is { ok: true } or { ok: false; reason: 'expired' | 'wrong' | 'locked' | 'none' }.
| Situation | Result | Record after |
|---|---|---|
No record for to | reason: 'none' | — |
Past exp | reason: 'expired' | Consumed — an expired code cannot be retried |
| Correct code | ok: true | Consumed — single use |
Wrong, still under maxAttempts | reason: 'wrong' | Persists, attempts + 1 |
Wrong, at/over maxAttempts | reason: 'locked' | Consumed — no further retries, even with the right code |
Expiry is evaluated against the injected now, in the same clock domain you passed to startLogin.
Storage
interface OtpStore {
put(to: string, record: OtpRecord): Promise<void>
get(to: string): Promise<OtpRecord | undefined>
del(to: string): Promise<void>
}
interface OtpRecord {
codeHash: string // SHA-256 hex — never the plaintext code
exp: number // absolute expiry instant
attempts: number // failed verifications so far
}memoryOtpStore() is a Map-backed reference implementation. It is not safe across processes — a multi-instance deployment needs Redis, Postgres, or equivalent behind the same three methods.
On hashOtp — what hashing does and does not buy you
Codes are stored as an unsalted SHA-256 digest. Being precise about the value of that:
- It does keep codes out of plain sight in a DB browser, a support-ticket screenshot, a log aggregator that ingests the store, or a backup — the common “it was right there in plaintext” leak.
- It does not resist offline brute force. A 6-digit code has only 1,000,000 possible values; all one million hashes compute in well under a second. An attacker holding a store dump recovers every live code.
What actually bounds an online guessing attack is verifyOtp’s maxAttempts lockout, not the hash.
Testability
Every time- and randomness-dependent input is injected — there is no Date.now() or Math.random() anywhere in the core. Pass a fixed now and a deterministic rng and the whole login flow becomes exactly reproducible, with no fake timers.
const codes = ['481920']
let i = 0
const rng = () => Number(codes[i++]) / 1_000_000
await startLogin({ store, sender, now: 1_700_000_000_000, rng }, { channel: 'email', to: '[email protected]' })Next steps
- @solidus-network/auth — DID challenge-response, for users who already hold a DID
- @solidus-network/sdk — issue and verify the credentials an OTP login mints a DID for