@cairs-npm/express-auth-2fa 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/dist/chunk-PQPLAWOZ.js +44 -0
- package/dist/chunk-PQPLAWOZ.js.map +1 -0
- package/dist/contract/index.cjs +25 -0
- package/dist/contract/index.cjs.map +1 -0
- package/dist/contract/index.d.cts +1 -0
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.js +3 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hash-GKCJDZLH.js +21 -0
- package/dist/hash-GKCJDZLH.js.map +1 -0
- package/dist/index.cjs +1759 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +577 -0
- package/dist/index.d.ts +577 -0
- package/dist/index.js +1682 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -4
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @cairs-npm/express-auth-2fa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Private Express + MongoDB module that adds password sign-in with 2-Step
|
|
4
|
+
Verification (TOTP authenticator apps + SMS codes), verification-method
|
|
5
|
+
management, session handling and a security audit log to any Express app.
|
|
6
|
+
|
|
7
|
+
## Install & mount
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { MongoClient } from "mongodb";
|
|
12
|
+
import { createAuthRouter, migrate, twilioSms } from "@cairs-npm/express-auth-2fa";
|
|
13
|
+
|
|
14
|
+
const db = new MongoClient(process.env.MONGO_URI!).db("app");
|
|
15
|
+
await migrate(db); // creates auth_* collections, validators, indexes, TTLs
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
app.use(express.json());
|
|
19
|
+
app.use(
|
|
20
|
+
createAuthRouter({
|
|
21
|
+
db,
|
|
22
|
+
issuer: "YourApp",
|
|
23
|
+
jwtPrivateKey: process.env.JWT_PRIVATE_KEY!, // PKCS8 PEM, Ed25519 or RSA
|
|
24
|
+
encryptionKey: process.env.AUTH_ENC_KEY!, // 32 bytes (raw or base64)
|
|
25
|
+
userAdapter: {
|
|
26
|
+
findByLogin: (login) => db.collection("users").findOne({ email: login }),
|
|
27
|
+
findById: (id) => db.collection("users").findOne({ _id: id }),
|
|
28
|
+
},
|
|
29
|
+
smsProvider: twilioSms({ sid, token, from }), // or consoleSms() in dev
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
One install, one line to mount. All endpoints live under `/auth/*` and
|
|
35
|
+
`/account/security*`, implementing the shared `@cairs-npm/auth-2fa-contract`.
|
|
36
|
+
|
|
37
|
+
## Config
|
|
38
|
+
|
|
39
|
+
| Field | Required | Default | Notes |
|
|
40
|
+
| --- | --- | --- | --- |
|
|
41
|
+
| `db` | yes | — | `mongodb` `Db`; the module owns `auth_*` collections only |
|
|
42
|
+
| `issuer` | yes | — | JWT issuer + otpauth issuer |
|
|
43
|
+
| `jwtPrivateKey` | yes | — | PKCS8 PEM; Ed25519 → EdDSA, RSA → RS256 |
|
|
44
|
+
| `encryptionKey` | yes | — | 32-byte AES-256-GCM key (raw string or base64) |
|
|
45
|
+
| `keyId` | no | `"v1"` | written on each encrypted field for rotation |
|
|
46
|
+
| `userAdapter` | yes | — | `findByLogin`, `findById` |
|
|
47
|
+
| `smsProvider` | yes | — | `{ send(phoneE164, message) }` |
|
|
48
|
+
| `logger` | no | noop | pino-compatible; output is redacted (NFR-B1) |
|
|
49
|
+
| `hooks.onSecurityEvent` | no | — | called for every audit event (BR-20) |
|
|
50
|
+
| `clock` | no | `Date.now` | inject for tests |
|
|
51
|
+
| `collectionPrefix` | no | `"auth_"` | |
|
|
52
|
+
| `limits` | no | spec | lockout, SMS resend/caps, per-IP rate limits |
|
|
53
|
+
| `argon2` | no | 64MB/3it/4p | Argon2id params |
|
|
54
|
+
| `cookies` | no | secure/lax | refresh + trusted-device + CSRF cookies |
|
|
55
|
+
| `ttl` | no | spec | access 15m, refresh/trusted 30d, challenge/stepUp 5m |
|
|
56
|
+
| `exposeDocs` | no | false | serves `GET /auth/openapi.json` |
|
|
57
|
+
|
|
58
|
+
## Data model
|
|
59
|
+
|
|
60
|
+
Six+ collections (`auth_credentials`, `auth_mfa_methods`, `auth_challenges`,
|
|
61
|
+
`auth_sessions`, `auth_trusted_devices`, `auth_events`, `auth_rate_limits`),
|
|
62
|
+
created by `migrate(db)` with JSON-schema validators, indexes and TTLs.
|
|
63
|
+
TOTP secrets and phone numbers are AES-256-GCM encrypted; SMS codes, refresh
|
|
64
|
+
tokens and trusted-device tokens are stored as SHA-256 hashes only.
|
|
65
|
+
|
|
66
|
+
## Security highlights
|
|
67
|
+
|
|
68
|
+
- Argon2id passwords; equal `INVALID_CREDENTIALS` timing for unknown users.
|
|
69
|
+
- Lockout after 5 failed passwords; challenge lockout after 5 wrong codes.
|
|
70
|
+
- TOTP RFC 6238 (±1 step) with per-method replay guard.
|
|
71
|
+
- Rotating refresh tokens; reuse of a rotated token revokes the whole family.
|
|
72
|
+
- CSRF double-submit token on the cookie-authenticated refresh route.
|
|
73
|
+
- Per-IP and per-user rate limits backed by MongoDB (stateless across instances).
|
|
74
|
+
- One error shape: `{ error, message, attemptsLeft?, retryAt? }`.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm install
|
|
80
|
+
npm test # vitest + mongodb-memory-server (replica set)
|
|
81
|
+
npm run build # tsup → ESM + CJS + d.ts
|
|
82
|
+
npm run typecheck && npm run lint
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`examples/express-app` is a runnable dev server for E2E testing.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// src/crypto/hash.ts
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import argon2 from "argon2";
|
|
4
|
+
function hashPassword(plain, params) {
|
|
5
|
+
return argon2.hash(plain, {
|
|
6
|
+
type: argon2.argon2id,
|
|
7
|
+
memoryCost: params.memoryKiB,
|
|
8
|
+
timeCost: params.iterations,
|
|
9
|
+
parallelism: params.parallelism
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function verifyPassword(hash, plain) {
|
|
13
|
+
return argon2.verify(hash, plain).catch(() => false);
|
|
14
|
+
}
|
|
15
|
+
var DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$1AkBK4sQubUqG5fSG5+0E4yP+4DpVXq3sfJv4boqK3k";
|
|
16
|
+
function sha256Hex(value) {
|
|
17
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
18
|
+
}
|
|
19
|
+
function timingSafeEqualStr(a, b) {
|
|
20
|
+
const ha = crypto.createHash("sha256").update(a).digest();
|
|
21
|
+
const hb = crypto.createHash("sha256").update(b).digest();
|
|
22
|
+
return crypto.timingSafeEqual(ha, hb);
|
|
23
|
+
}
|
|
24
|
+
function randomToken() {
|
|
25
|
+
return crypto.randomBytes(32).toString("base64url");
|
|
26
|
+
}
|
|
27
|
+
function randomSmsCode() {
|
|
28
|
+
return crypto.randomInt(0, 1e6).toString().padStart(6, "0");
|
|
29
|
+
}
|
|
30
|
+
function randomId(prefix) {
|
|
31
|
+
return `${prefix}_${crypto.randomBytes(9).toString("base64url")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
hashPassword,
|
|
36
|
+
verifyPassword,
|
|
37
|
+
DUMMY_HASH,
|
|
38
|
+
sha256Hex,
|
|
39
|
+
timingSafeEqualStr,
|
|
40
|
+
randomToken,
|
|
41
|
+
randomSmsCode,
|
|
42
|
+
randomId
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=chunk-PQPLAWOZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/crypto/hash.ts"],"sourcesContent":["import crypto from \"node:crypto\";\nimport argon2 from \"argon2\";\n\nexport interface Argon2Params {\n memoryKiB: number;\n iterations: number;\n parallelism: number;\n}\n\nexport function hashPassword(plain: string, params: Argon2Params): Promise<string> {\n return argon2.hash(plain, {\n type: argon2.argon2id,\n memoryCost: params.memoryKiB,\n timeCost: params.iterations,\n parallelism: params.parallelism,\n });\n}\n\nexport function verifyPassword(hash: string, plain: string): Promise<boolean> {\n return argon2.verify(hash, plain).catch(() => false);\n}\n\n// Dummy hash used to equalize timing for unknown users (BR-1). Argon2id format.\nexport const DUMMY_HASH =\n \"$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$1AkBK4sQubUqG5fSG5+0E4yP+4DpVXq3sfJv4boqK3k\";\n\nexport function sha256Hex(value: string): string {\n return crypto.createHash(\"sha256\").update(value, \"utf8\").digest(\"hex\");\n}\n\n/** Constant-time string comparison via SHA-256 (BR-8). */\nexport function timingSafeEqualStr(a: string, b: string): boolean {\n const ha = crypto.createHash(\"sha256\").update(a).digest();\n const hb = crypto.createHash(\"sha256\").update(b).digest();\n return crypto.timingSafeEqual(ha, hb);\n}\n\nexport function randomToken(): string {\n return crypto.randomBytes(32).toString(\"base64url\");\n}\n\nexport function randomSmsCode(): string {\n return crypto.randomInt(0, 1_000_000).toString().padStart(6, \"0\");\n}\n\nexport function randomId(prefix: string): string {\n return `${prefix}_${crypto.randomBytes(9).toString(\"base64url\")}`;\n}\n"],"mappings":";AAAA,OAAO,YAAY;AACnB,OAAO,YAAY;AAQZ,SAAS,aAAa,OAAe,QAAuC;AACjF,SAAO,OAAO,KAAK,OAAO;AAAA,IACxB,MAAM,OAAO;AAAA,IACb,YAAY,OAAO;AAAA,IACnB,UAAU,OAAO;AAAA,IACjB,aAAa,OAAO;AAAA,EACtB,CAAC;AACH;AAEO,SAAS,eAAe,MAAc,OAAiC;AAC5E,SAAO,OAAO,OAAO,MAAM,KAAK,EAAE,MAAM,MAAM,KAAK;AACrD;AAGO,IAAM,aACX;AAEK,SAAS,UAAU,OAAuB;AAC/C,SAAO,OAAO,WAAW,QAAQ,EAAE,OAAO,OAAO,MAAM,EAAE,OAAO,KAAK;AACvE;AAGO,SAAS,mBAAmB,GAAW,GAAoB;AAChE,QAAM,KAAK,OAAO,WAAW,QAAQ,EAAE,OAAO,CAAC,EAAE,OAAO;AACxD,QAAM,KAAK,OAAO,WAAW,QAAQ,EAAE,OAAO,CAAC,EAAE,OAAO;AACxD,SAAO,OAAO,gBAAgB,IAAI,EAAE;AACtC;AAEO,SAAS,cAAsB;AACpC,SAAO,OAAO,YAAY,EAAE,EAAE,SAAS,WAAW;AACpD;AAEO,SAAS,gBAAwB;AACtC,SAAO,OAAO,UAAU,GAAG,GAAS,EAAE,SAAS,EAAE,SAAS,GAAG,GAAG;AAClE;AAEO,SAAS,SAAS,QAAwB;AAC/C,SAAO,GAAG,MAAM,IAAI,OAAO,YAAY,CAAC,EAAE,SAAS,WAAW,CAAC;AACjE;","names":[]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __copyProps = (to, from, except, desc) => {
|
|
7
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
8
|
+
for (let key of __getOwnPropNames(from))
|
|
9
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
10
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
11
|
+
}
|
|
12
|
+
return to;
|
|
13
|
+
};
|
|
14
|
+
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
15
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
16
|
+
|
|
17
|
+
// src/contract/index.ts
|
|
18
|
+
var contract_exports = {};
|
|
19
|
+
module.exports = __toCommonJS(contract_exports);
|
|
20
|
+
__reExport(contract_exports, require("@cairs-npm/auth-2fa-contract"), module.exports);
|
|
21
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
22
|
+
0 && (module.exports = {
|
|
23
|
+
...require("@cairs-npm/auth-2fa-contract")
|
|
24
|
+
});
|
|
25
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/contract/index.ts"],"sourcesContent":["// Re-export the shared contract so backend consumers get it transitively.\nexport * from \"@cairs-npm/auth-2fa-contract\";\n"],"mappings":";;;;;;;;;;;;;;;;;AAAA;AAAA;AACA,6BAAc,yCADd;","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@cairs-npm/auth-2fa-contract';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@cairs-npm/auth-2fa-contract';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/contract/index.ts"],"sourcesContent":["// Re-export the shared contract so backend consumers get it transitively.\nexport * from \"@cairs-npm/auth-2fa-contract\";\n"],"mappings":";AACA,cAAc;","names":[]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DUMMY_HASH,
|
|
3
|
+
hashPassword,
|
|
4
|
+
randomId,
|
|
5
|
+
randomSmsCode,
|
|
6
|
+
randomToken,
|
|
7
|
+
sha256Hex,
|
|
8
|
+
timingSafeEqualStr,
|
|
9
|
+
verifyPassword
|
|
10
|
+
} from "./chunk-PQPLAWOZ.js";
|
|
11
|
+
export {
|
|
12
|
+
DUMMY_HASH,
|
|
13
|
+
hashPassword,
|
|
14
|
+
randomId,
|
|
15
|
+
randomSmsCode,
|
|
16
|
+
randomToken,
|
|
17
|
+
sha256Hex,
|
|
18
|
+
timingSafeEqualStr,
|
|
19
|
+
verifyPassword
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=hash-GKCJDZLH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|