Skip to Content
SDK@solidus-network/auth-otp

@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-otp
import { 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 notBecause
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 channel parameter accepts 'sms', and the core is channel-agnostic — but this package contains no real SMS adapter. The email channel is fully usable today through any OtpSender you 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

NameTypeDefaultDescription
deps.storeOtpStoreWhere the hashed code is held
deps.senderOtpSenderYour delivery adapter
deps.nownumberCurrent instant, injected — the package never calls Date.now()
deps.rng() => numberMath.randomMust return a float in [0, 1). Inject a CSPRNG in production
deps.expiresInMsnumberCode lifetime
args.channel'sms' | 'email'Passed through to your sender
args.tostringPhone 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' }.

SituationResultRecord after
No record for toreason: 'none'
Past expreason: 'expired'Consumed — an expired code cannot be retried
Correct codeok: trueConsumed — single use
Wrong, still under maxAttemptsreason: 'wrong'Persists, attempts + 1
Wrong, at/over maxAttemptsreason: '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

Last updated on