@solidus-network/auth-otp
v0.1.0
Published
Pure, provider-agnostic OTP (SMS/email) login core for the Solidus Network protocol — injected clock, rng, store, sender, and identity resolver; the caller owns session issuance and delivery.
Maintainers
Readme
@solidus-network/auth-otp
A pure, provider-agnostic OTP (SMS/email) login core for the Solidus Network protocol. It generates, stores, and verifies one-time codes with an injected clock, RNG, and store — and hands delivery and identity resolution to the caller through two small interfaces. It does nothing useful on its own: you bring your own sender and your own session issuer.
Status — read this first
- First release (0.1.0). New package, testnet-grade.
- Zero
@solidus/*runtime dependencies. This package never imports a DID SDK, a JWT signer, or an SMS provider SDK — see "What this package deliberately does NOT do" below. - Only the pure core ships today. A real SMS sender adapter is a founder-gated follow-on
(provider account, credentials, a test handset) — not a code-readiness question. The email
channel is fully usable today via any
OtpSenderyou wire in (e.g. Resend, SES, SMTP); the sms channel is structurally supported (thechannelparam accepts'sms') but this package ships no real SMS sender itself.
What this package does
generateOtp(rng?)— a 6-digit numeric code from an injected RNG (defaults toMath.random; production callers should inject a CSPRNG).memoryOtpStore()— a reference, in-memoryOtpStore(generate is store-agnostic; swap in Redis/Postgres/etc. by implementing the same three-method interface).verifyOtp(store, { to, code, now, maxAttempts })— expiry (via injectednow), single-use consumption, attempt-count lockout, constant-time code comparison.startLogin(deps, { channel, to })— generates a code, stores it, and calls your injectedOtpSender.send(channel, to, code). Returns nothing; the code is never in the return value or in any log this function touches.completeLogin(deps, { to, code })— verifies the code, and only on success calls your injectedresolvePrincipal(to)to find or mint the DID behind that phone number/email. Returns{ did, isNew }or{ error }— nothing else.
What this package deliberately does NOT do
It does not send anything.
OtpSenderis an interface you implement:interface OtpSender { send(channel: 'sms' | 'email', to: string, code: string): Promise<void> }Twilio, Vonage, Resend, SES, a test double — this package never imports a provider SDK, and never will. Delivery receipts, sender IDs, and regional routing are the adapter's problem, not this package's.
It does not resolve or mint identity.
resolvePrincipal(to) => Promise<{ did, isNew }>is yours to implement. Minting a freshdid:solidusfor a first-time phone number means generating and custodying a keypair — a decision this package refuses to make on your behalf. Look up an existing DID, mint a new one with your own custody model, or reject unknown numbers entirely;auth-otponly calls the function you give it.It does not issue a session.
completeLoginstops at{ did, isNew }. Signing a JWT/session token is the calling backend's job, using whatever signer it already has (in the Solidus monorepo, that's the internal@solidus/jwtpackage — deliberately never a dependency of this package, since it is workspace-private and cannot resolve for an outside installer). Take the returneddid, put it in your own session payload, sign it with your own key.
Install
npm i @solidus-network/auth-otpExample
import { memoryOtpStore, startLogin, completeLogin } from '@solidus-network/auth-otp'
import type { OtpSender } from '@solidus-network/auth-otp'
const store = memoryOtpStore()
// Stand-in sender — replace with a real Twilio/Vonage/Resend/etc. adapter.
const sender: OtpSender = {
async send(channel, to, code) {
console.log(`[dev only] would send ${channel} code ${code} to ${to}`)
},
}
// Your own identity resolution — look up or mint a DID however your product does it.
async function resolvePrincipal(to: string) {
return { did: `did:solidus:testnet:stub-${to}`, isNew: true }
}
await startLogin({ store, sender, now: Date.now() }, { channel: 'sms', to: '+15555550100' })
// ...user reads the code from their phone/inbox and submits it...
const result = await completeLogin(
{ store, resolvePrincipal, now: Date.now() },
{ to: '+15555550100', code: '123456' },
)
if ('error' in result) {
// 'expired' | 'wrong' | 'locked' | 'none'
console.log('login failed:', result.error)
} else {
// { did, isNew } — now sign your own session with your own JWT/session library.
console.log('resolved principal:', result.did)
}Security defaults
- Hashed at rest.
OtpStorenever holds the plaintext code — only a SHA-256 hex digest (hashOtp,node:crypto, no new dependency). This is defense against the code showing up in plain sight in a DB browser, a log aggregator, or a backup — not a substitute for the rate limit below. A 6-digit code has only 1,000,000 possible values, so an unsalted hash does not resist offline brute force against a store dump; what actually bounds a live guessing attack ismaxAttempts. - Codes expire (default 5 minutes) — configurable via
startLogin'sexpiresInMs. - Max verification attempts (default 5) before lockout — configurable via
maxAttempts. - Single-use: a correct code is consumed (deleted from the store) on success.
- Constant-time comparison — code verification (of the hash) does not leak timing information about how many characters of a guess were correct.
- The code is never returned by
startLogin/completeLogin, and never logged by this package. The only place a real code appears in a call stack is inside your ownOtpSender.send.
License
Apache-2.0
