@neuraiproject/neurai-auth
v0.0.2
Published
Verify Sign-in-with-Neurai logins on a Node backend: login transactions bound to the browser pre-session, atomic CACAO verification with neurai-message.
Maintainers
Readme
@neuraiproject/neurai-auth
Backend verification of Sign in with Neurai for Node: the user signs a CAIP-122 message with their Neurai wallet, the browser posts the resulting CACAO to your site, and this package decides whether that is a login.
It does two things:
- The login transaction (spec/auth.md section 7). Every attempt is a transaction bound to the browser's pre-session cookie, with its own nonce, pairing topic, domain, URI, chains and a 10-minute window. It is consumed atomically and exactly once.
- The CACAO verification (section 6). The canonical text is rebuilt from the payload, the chain and the
signature type are checked against the address, and the signature is verified with
@neuraiproject/neurai-message— the same primitive the wallet and the browser extension use.
Nothing that comes back from the browser is used without being contrasted with the stored transaction.
npm install @neuraiproject/neurai-authFrontend (5 lines)
The pairing is created before /auth/begin, because its topic is the requestId that binds the signature
to this QR code. The authPayload comes from the backend, so the frontend cannot drift from what will be
verified.
const pairing = await nc.createPairing(); // topic P = requestId
const { loginId, authPayload } = await api.post("/auth/begin", { requestId: pairing.topic });
const { uri, response } = await nc.authenticate({ pairing, ...authPayload }); // show `uri` as the QR code
const { cacaos } = await response; // resolves when the user approves
await api.post("/auth/complete", { loginId, cacao: cacaos[0] }); // same pre-session cookieBackend (10 lines)
import { NeuraiAuth, neuraiAuthRoutes } from "@neuraiproject/neurai-auth";
const auth = new NeuraiAuth({
domain: "example.com", // fixed here, never taken from the browser
uri: "https://example.com/login",
chains: ["bip122:00000044d33c0c0ba019be5c02497304"],
ttlSeconds: 600,
});
const routes = neuraiAuthRoutes(auth, { onLogin: (login, req) => { req.session.user = login.address; } });
app.post("/auth/begin", routes.begin);
app.post("/auth/complete", routes.complete);Or without the adapters, if you already have your own session middleware:
app.post("/auth/begin", async (req, res) => {
const begun = await auth.begin(req.preSession.id, { requestId: req.body.requestId, addressPolicy: "wallet" });
res.json({ loginId: begun.loginId, nonce: begun.nonce, authPayload: auth.buildAuthPayload(begun) });
});
app.post("/auth/complete", async (req, res) => {
// Consumes the transaction and checks nonce, domain, aud, requestId, chain, window and signature.
const { address, chainId } = await auth.complete(req.preSession.id, req.body.loginId, req.body.cacao);
req.session.user = address; // issue your session
req.preSession.destroy(); // and destroy the pre-session
res.sendStatus(204);
});Mounting the HTTP adapters
Both adapters are optional and depend on no framework: req and res are typed structurally, so nothing is
installed on your behalf. They are the same handlers with different plumbing.
// Express (cookie-parser optional: the `Cookie` header is parsed when `req.cookies` is absent)
import express, { type Request, type Response } from "express";
import { NeuraiAuth, neuraiAuthRoutes } from "@neuraiproject/neurai-auth";
const app = express();
app.use(express.json());
// The type arguments are optional; give them and `onLogin` sees your own Request/Response (`req.session`, …).
const routes = neuraiAuthRoutes<Request, Response>(auth, {
preSessionCookie: "nc_presession",
cookieOptions: { httpOnly: true, sameSite: "Lax", secure: true, path: "/" }, // the defaults
onLogin: async (login, req) => { req.session.user = login.address; }, // issue your session here
onError: (error) => logger.error(error),
});
app.post("/auth/begin", routes.begin);
app.post("/auth/complete", routes.complete);// Fastify (the same factory, exported as `fastifyNeuraiAuthRoutes` from the package root)
import type { FastifyReply, FastifyRequest } from "fastify";
import { fastifyNeuraiAuthRoutes } from "@neuraiproject/neurai-auth";
const routes = fastifyNeuraiAuthRoutes<FastifyRequest, FastifyReply>(auth, {
onLogin: (login, req) => { req.session.user = login.address; },
});
fastify.post("/auth/begin", routes.begin);
fastify.post("/auth/complete", routes.complete);The pre-session cookie:
HttpOnly, so no script can read it;SameSite=Lax, so a third-party page cannot drive the callback;Secure, so it never travels in the clear (drop it only onhttp://localhostduring development);Path=/andMax-Ageequal to the transaction TTL.beginmints one (16 random bytes in hex) when the browser has none, and keeps the existing one otherwise. If your app already has a pre-session, pass its id toauth.begindirectly instead of using the adapters.- The pre-session is destroyed on success:
completeanswers withSet-Cookie: nc_presession=; Max-Age=0. YouronLoginruns before the response is written, which is where you issue the real session cookie, so the browser leaves the exchange with a user session and no pre-session.
Responses: begin answers 200 {loginId, nonce, authPayload} or 400 {error: {code, message}}; complete
answers 200 {address, chainId} or 401 {error: {code, message}} with the codes below.
Security model
- The login transaction is bound to the pre-session that started it. An attacker who obtains a valid CACAO for their own account cannot make a victim's browser submit it (login-CSRF): the transaction belongs to the attacker's cookie, not the victim's.
- Atomic single use.
completeconsumes the transaction in one indivisible operation before any other check, so a second callback with the sameloginIdfails withunknown_transactioneven when its CACAO is perfectly valid, and two concurrent callbacks cannot both win.MemoryTransactionStoredoes it without awaiting between read and mark;RedisTransactionStoredoes it with a LuaGET+DELscript (or the nativeGETDEL), which is atomic across processes. - Domain, URI, chains and nonce are the backend's, never the browser's.
requestIdis the pairing topic the frontend registered atbegin, so a signature made for another QR code does not authenticate here. addressPolicyis a hint, not a condition (section 7.2). From a CACAO the backend sees a valid address but knows neither the seed nor the derivation path, so it cannot tell an account-0 wallet address from an account-101 per-domain identity address. It is stored as informational data and never causes a rejection.- Balance and asset checks happen on-chain, after login, with
@neuraiproject/neurai-lockor Blockbook, against whatever address signed. Do not try to infer holdings from the login itself. - A relayed QR code (QRLjacking) remains a residual risk (section 9): the wallet warns that the browser showing the QR will be connected, transactions expire in 10 minutes, and sensitive actions should ask the already-authenticated browser to confirm.
- CAIP-122
version"1"only. A future"2"must be accepted alongside"1"for at least six months (ACCEPTED_CAIP122_VERSIONS).
Without a framework
handleBegin and handleComplete are the whole surface; the Express and Fastify files are thin wrappers over them, so plain node:http works too:
import { NeuraiAuth, handleBegin, handleComplete, parseCookieHeader } from "@neuraiproject/neurai-auth";
const auth = new NeuraiAuth({ domain: "example.com", uri: "https://example.com/login", chains });
if (req.url === "/auth/begin" && req.method === "POST") {
const preSessionId = parseCookieHeader(req.headers.cookie).nc_presession; // undefined on a first visit
const outcome = await handleBegin(auth, { preSessionCookie: "nc_presession" }, { preSessionId, body: await readJson(req) });
if (outcome.setCookie) res.setHeader("set-cookie", outcome.setCookie); // mints the pre-session when absent
res.writeHead(outcome.status, { "content-type": "application/json" }).end(JSON.stringify(outcome.body));
}The outcome carries { status, body }; on success at /auth/complete it also carries the verified address, and the caller destroys the pre-session cookie and issues its own session. A complete, runnable example lives in apps/demo-login/server.mjs.
Error codes
Every rejection is an AuthError with a stable code (isAuthError(error) narrows it). The message is for
humans; the code is the contract.
| code | Where | Meaning |
| --- | --- | --- |
| invalid_config | constructor, verifyCacao | Missing domain/uri, empty or non-CAIP-2 chains, bad ttlSeconds/nonceBytes. |
| invalid_pre_session | begin, complete | No pre-session identifier (no nc_presession cookie). |
| invalid_request_id | begin | requestId is not a pairing topic: 64 lowercase hex characters. |
| statement_newline | begin, complete | The statement contains \n, which could forge canonical lines. |
| unknown_transaction | complete | No such loginId, or it was already used: single use. |
| wrong_pre_session | complete | The transaction belongs to another browser. The transaction is burnt anyway. |
| expired | complete | The 10-minute window elapsed. |
| nonce_mismatch | complete | The signed nonce is not this transaction's (a CACAO from another attempt). |
| domain_mismatch | complete | The signed domain is not the configured one. |
| aud_mismatch | complete | The signed aud is not the configured login URI. |
| request_id_mismatch | complete | The signed requestId is not the registered pairing topic. |
| timestamps_out_of_window | complete | iat/exp/nbf are unparseable or inconsistent with the window. |
| invalid_shape | complete, verifyCacao | Not a CACAO: header, payload fields or iss are malformed. |
| version_unsupported | verifyCacao | CAIP-122 version other than "1". |
| unsupported_chain | verifyCacao | The chain of iss is not among the accepted ones. |
| signature_type_mismatch | verifyCacao | s.t does not match the address (neurai-secp256k1-compact for legacy N…/t… and ECDSA witness v3 nq1r…/tnq1r…, neurai-ml-dsa-44 for AuthScript witness v1 nc1p…/tnc1p… and PQ witness v2 pq1z…/tpq1z…), or the address is not one that can sign messages (P2SH, the retired nq1p…/tnq1p… encoding). |
| invalid_signature | verifyCacao | neurai-message did not validate the signature over the canonical text. |
Stores
MemoryTransactionStore (the default) is a Map with periodic pruning: fine for one process, useless for two,
since a login begun on one would not be completable on the other. For several processes inject
RedisTransactionStore with a small adapter over your client — ioredis is not a dependency of this
package:
import Redis from "ioredis";
import { NeuraiAuth, RedisTransactionStore } from "@neuraiproject/neurai-auth";
const redis = new Redis(process.env.REDIS_URL);
const store = new RedisTransactionStore({
set: (key, value, opts) => (opts?.px ? redis.set(key, value, "PX", opts.px) : redis.set(key, value)),
get: (key) => redis.get(key),
del: (key) => redis.del(key),
eval: (script, keys, args) => redis.eval(script, keys.length, ...keys, ...args),
});
const auth = new NeuraiAuth({ domain, uri, chains, store });consume is one round trip: the Lua script does GET and DEL inside a single script (Redis runs a script to
completion before any other command), so exactly one of two concurrent callbacks gets the transaction. A client
that offers neither eval nor getdel is rejected in the constructor instead of silently downgraded to a racy
GET + DEL. Entries expire with SET … PX, so prune has nothing to do. Any storage with an atomic
read-and-mark works: implement TransactionStore and keep consume indivisible.
Verifying a CACAO on its own
verifyCacao is pure — no clock, no state, no I/O — so a third party can validate a Neurai login signature
without knowing anything about this package's transactions:
import { verifyCacao } from "@neuraiproject/neurai-auth";
const { address, chainId, message } = verifyCacao(cacao, {
chains: ["bip122:00000044d33c0c0ba019be5c02497304"],
});It checks shape, version, iss, chain, signature type, the canonical reconstruction and the signature. It does
not check nonce, domain, aud, requestId or expiry: that is what the login transaction is for.
Public API
NeuraiAuth (begin, buildAuthPayload, complete), verifyCacao, AuthError / isAuthError /
AuthErrorCode, ACCEPTED_CAIP122_VERSIONS, toRfc3339, the stores (TransactionStore,
MemoryTransactionStore, RedisTransactionStore, MinimalRedisClient, LoginTransaction) and the adapters
(neuraiAuthRoutes for Express, fastifyNeuraiAuthRoutes for Fastify, plus the cookie helpers
parseCookieHeader / serializeCookie).
Specification: spec/auth.md (sections 3 to 8) and spec/session.md
section 4 for the chain identifiers. Test vectors: spec/vectors/auth/neurai-message-0.11.0.json.
