@lastshotlabs/bunshot 0.0.16 → 0.0.18
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 +322 -16
- package/dist/adapters/memoryAuth.d.ts +3 -0
- package/dist/adapters/memoryAuth.js +48 -2
- package/dist/adapters/mongoAuth.js +39 -1
- package/dist/adapters/sqliteAuth.d.ts +3 -0
- package/dist/adapters/sqliteAuth.js +53 -0
- package/dist/app.d.ts +45 -2
- package/dist/app.js +79 -4
- package/dist/index.d.ts +14 -7
- package/dist/index.js +8 -4
- package/dist/lib/appConfig.d.ts +35 -0
- package/dist/lib/appConfig.js +10 -0
- package/dist/lib/authAdapter.d.ts +24 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.js +17 -4
- package/dist/lib/mfaChallenge.d.ts +23 -1
- package/dist/lib/mfaChallenge.js +151 -42
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/session.js +6 -4
- package/dist/lib/ws.js +5 -1
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +14 -2
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.js +2 -3
- package/dist/models/AuthUser.d.ts +9 -0
- package/dist/models/AuthUser.js +9 -0
- package/dist/routes/auth.js +21 -9
- package/dist/routes/mfa.d.ts +5 -1
- package/dist/routes/mfa.js +221 -14
- package/dist/routes/oauth.js +274 -10
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +10 -3
- package/dist/services/auth.d.ts +1 -0
- package/dist/services/auth.js +21 -5
- package/dist/services/mfa.d.ts +47 -0
- package/dist/services/mfa.js +276 -9
- package/dist/ws/index.js +3 -2
- package/docs/sections/auth-flow/full.md +180 -2
- package/docs/sections/configuration/full.md +20 -0
- package/docs/sections/configuration/overview.md +1 -1
- package/docs/sections/configuration-example/full.md +19 -1
- package/docs/sections/exports/full.md +11 -2
- package/docs/sections/multi-tenancy/full.md +5 -1
- package/docs/sections/oauth/full.md +80 -10
- package/docs/sections/oauth/overview.md +2 -2
- package/docs/sections/peer-dependencies/full.md +6 -2
- package/docs/sections/response-caching/full.md +3 -1
- package/docs/sections/websocket/full.md +4 -3
- package/docs/sections/websocket/overview.md +1 -1
- package/package.json +16 -4
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type OAuthCodeStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
2
|
+
export declare const setOAuthCodeStore: (store: OAuthCodeStore) => void;
|
|
3
|
+
export interface OAuthCodePayload {
|
|
4
|
+
token: string;
|
|
5
|
+
userId: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
refreshToken?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Store a one-time authorization code. Returns the raw code (for the redirect URL).
|
|
10
|
+
* Only the SHA-256 hash is persisted. */
|
|
11
|
+
export declare const storeOAuthCode: (payload: OAuthCodePayload) => Promise<string>;
|
|
12
|
+
/** Atomically consume an authorization code — returns its payload and deletes it.
|
|
13
|
+
* Returns null if invalid, expired, or already used. */
|
|
14
|
+
export declare const consumeOAuthCode: (code: string) => Promise<OAuthCodePayload | null>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getRedis } from "./redis";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
3
|
+
import { getAppName } from "./appConfig";
|
|
4
|
+
import { sha256 } from "./crypto";
|
|
5
|
+
import { memoryStoreOAuthCode, memoryConsumeOAuthCode, } from "../adapters/memoryAuth";
|
|
6
|
+
import { sqliteStoreOAuthCode, sqliteConsumeOAuthCode, } from "../adapters/sqliteAuth";
|
|
7
|
+
function getOAuthCodeModel() {
|
|
8
|
+
if (appConnection.models["OAuthCode"])
|
|
9
|
+
return appConnection.models["OAuthCode"];
|
|
10
|
+
const { Schema } = mongoose;
|
|
11
|
+
const schema = new Schema({
|
|
12
|
+
codeHash: { type: String, required: true, unique: true },
|
|
13
|
+
token: { type: String, required: true },
|
|
14
|
+
userId: { type: String, required: true },
|
|
15
|
+
email: { type: String },
|
|
16
|
+
refreshToken: { type: String },
|
|
17
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
18
|
+
}, { collection: "oauth_codes" });
|
|
19
|
+
return appConnection.model("OAuthCode", schema);
|
|
20
|
+
}
|
|
21
|
+
let _store = "redis";
|
|
22
|
+
export const setOAuthCodeStore = (store) => { _store = store; };
|
|
23
|
+
const CODE_TTL = 60; // 60 seconds
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Public API
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Store a one-time authorization code. Returns the raw code (for the redirect URL).
|
|
28
|
+
* Only the SHA-256 hash is persisted. */
|
|
29
|
+
export const storeOAuthCode = async (payload) => {
|
|
30
|
+
const code = crypto.randomUUID();
|
|
31
|
+
const hash = sha256(code);
|
|
32
|
+
if (_store === "memory") {
|
|
33
|
+
memoryStoreOAuthCode(hash, payload, CODE_TTL);
|
|
34
|
+
return code;
|
|
35
|
+
}
|
|
36
|
+
if (_store === "sqlite") {
|
|
37
|
+
sqliteStoreOAuthCode(hash, payload, CODE_TTL);
|
|
38
|
+
return code;
|
|
39
|
+
}
|
|
40
|
+
if (_store === "mongo") {
|
|
41
|
+
await getOAuthCodeModel().create({
|
|
42
|
+
codeHash: hash,
|
|
43
|
+
...payload,
|
|
44
|
+
expiresAt: new Date(Date.now() + CODE_TTL * 1000),
|
|
45
|
+
});
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
// Redis
|
|
49
|
+
await getRedis().set(`oauthcode:${getAppName()}:${hash}`, JSON.stringify(payload), "EX", CODE_TTL);
|
|
50
|
+
return code;
|
|
51
|
+
};
|
|
52
|
+
/** Atomically consume an authorization code — returns its payload and deletes it.
|
|
53
|
+
* Returns null if invalid, expired, or already used. */
|
|
54
|
+
export const consumeOAuthCode = async (code) => {
|
|
55
|
+
const hash = sha256(code);
|
|
56
|
+
if (_store === "memory")
|
|
57
|
+
return memoryConsumeOAuthCode(hash);
|
|
58
|
+
if (_store === "sqlite")
|
|
59
|
+
return sqliteConsumeOAuthCode(hash);
|
|
60
|
+
if (_store === "mongo") {
|
|
61
|
+
const doc = await getOAuthCodeModel()
|
|
62
|
+
.findOneAndDelete({ codeHash: hash, expiresAt: { $gt: new Date() } })
|
|
63
|
+
.lean();
|
|
64
|
+
if (!doc)
|
|
65
|
+
return null;
|
|
66
|
+
return { token: doc.token, userId: doc.userId, email: doc.email, refreshToken: doc.refreshToken };
|
|
67
|
+
}
|
|
68
|
+
// Redis
|
|
69
|
+
const key = `oauthcode:${getAppName()}:${hash}`;
|
|
70
|
+
const redis = getRedis();
|
|
71
|
+
let raw = null;
|
|
72
|
+
if (typeof redis.getdel === "function") {
|
|
73
|
+
try {
|
|
74
|
+
raw = await redis.getdel(key);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
raw = await redis.get(key);
|
|
78
|
+
if (raw)
|
|
79
|
+
await redis.del(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
raw = await redis.get(key);
|
|
84
|
+
if (raw)
|
|
85
|
+
await redis.del(key);
|
|
86
|
+
}
|
|
87
|
+
if (!raw)
|
|
88
|
+
return null;
|
|
89
|
+
return JSON.parse(raw);
|
|
90
|
+
};
|
|
@@ -1,24 +1,20 @@
|
|
|
1
|
-
import { createHash } from "crypto";
|
|
2
1
|
import { getRedis } from "./redis";
|
|
3
|
-
import { appConnection } from "./mongo";
|
|
2
|
+
import { appConnection, mongoose } from "./mongo";
|
|
4
3
|
import { getAppName, getResetTokenExpiry } from "./appConfig";
|
|
5
|
-
import { Schema } from "mongoose";
|
|
6
4
|
import { sqliteCreateResetToken, sqliteConsumeResetToken, } from "../adapters/sqliteAuth";
|
|
7
5
|
import { memoryCreateResetToken, memoryConsumeResetToken, } from "../adapters/memoryAuth";
|
|
8
|
-
|
|
9
|
-
// Token hashing — store SHA-256(token); raw token is only in the email link.
|
|
10
|
-
// If the store is ever leaked, outstanding tokens cannot be replayed directly.
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
const hashToken = (token) => createHash("sha256").update(token).digest("hex");
|
|
13
|
-
const resetSchema = new Schema({
|
|
14
|
-
token: { type: String, required: true, unique: true },
|
|
15
|
-
userId: { type: String, required: true },
|
|
16
|
-
email: { type: String, required: true },
|
|
17
|
-
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
18
|
-
}, { collection: "password_resets" });
|
|
6
|
+
import { sha256 as hashToken } from "./crypto";
|
|
19
7
|
function getResetModel() {
|
|
20
|
-
|
|
21
|
-
appConnection.
|
|
8
|
+
if (appConnection.models["PasswordReset"])
|
|
9
|
+
return appConnection.models["PasswordReset"];
|
|
10
|
+
const { Schema } = mongoose;
|
|
11
|
+
const resetSchema = new Schema({
|
|
12
|
+
token: { type: String, required: true, unique: true },
|
|
13
|
+
userId: { type: String, required: true },
|
|
14
|
+
email: { type: String, required: true },
|
|
15
|
+
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
|
|
16
|
+
}, { collection: "password_resets" });
|
|
17
|
+
return appConnection.model("PasswordReset", resetSchema);
|
|
22
18
|
}
|
|
23
19
|
// ---------------------------------------------------------------------------
|
|
24
20
|
// Redis helpers
|
package/dist/lib/session.js
CHANGED
|
@@ -16,11 +16,11 @@ function getSessionModel() {
|
|
|
16
16
|
expiresAt: { type: Date, required: true },
|
|
17
17
|
ipAddress: { type: String },
|
|
18
18
|
userAgent: { type: String },
|
|
19
|
-
refreshToken: { type: String, default: null
|
|
19
|
+
refreshToken: { type: String, default: null },
|
|
20
20
|
prevRefreshToken: { type: String, default: null },
|
|
21
21
|
prevTokenExpiresAt: { type: Date, default: null },
|
|
22
22
|
}, { collection: "sessions", timestamps: false });
|
|
23
|
-
sessionSchema.index({ refreshToken: 1 }, {
|
|
23
|
+
sessionSchema.index({ refreshToken: 1 }, { unique: true, partialFilterExpression: { refreshToken: { $type: "string" } } });
|
|
24
24
|
// Add TTL index only when metadata is not persisted — docs auto-delete at expiresAt.
|
|
25
25
|
// When persisting, token is nulled (soft-delete) but the row is kept indefinitely.
|
|
26
26
|
if (!getPersistSessionMetadata()) {
|
|
@@ -234,9 +234,11 @@ async function redisRotateRefreshToken(sessionId, newRefreshToken, newAccessToke
|
|
|
234
234
|
await redis.set(redisSessionKey(sessionId), JSON.stringify(rec));
|
|
235
235
|
// Set new reverse-lookup with full refresh expiry
|
|
236
236
|
await redis.set(redisRefreshTokenKey(newRefreshToken), sessionId, "EX", refreshExpiry);
|
|
237
|
-
// Update old reverse-lookup to expire after grace window
|
|
237
|
+
// Update old reverse-lookup to expire after grace window.
|
|
238
|
+
// Use a minimum TTL of 60s so that theft detection still works when grace is 0.
|
|
238
239
|
if (oldRefreshToken) {
|
|
239
|
-
|
|
240
|
+
const oldKeyTtl = Math.max(graceSeconds, 60);
|
|
241
|
+
await redis.expire(redisRefreshTokenKey(oldRefreshToken), oldKeyTtl);
|
|
240
242
|
}
|
|
241
243
|
}
|
|
242
244
|
// ---------------------------------------------------------------------------
|
package/dist/lib/ws.js
CHANGED
|
@@ -11,9 +11,13 @@ const _roomRegistry = new Map();
|
|
|
11
11
|
export const getRooms = () => [..._roomRegistry.keys()];
|
|
12
12
|
/** Socket IDs subscribed to a given room */
|
|
13
13
|
export const getRoomSubscribers = (room) => [...(_roomRegistry.get(room) ?? [])];
|
|
14
|
+
const MAX_ROOM_ACTION_SIZE = 4096; // 4 KB — room actions are small JSON payloads
|
|
14
15
|
export const handleRoomActions = async (ws, message, onSubscribe) => {
|
|
15
16
|
try {
|
|
16
|
-
const
|
|
17
|
+
const raw = typeof message === "string" ? message : Buffer.from(message).toString();
|
|
18
|
+
if (raw.length > MAX_ROOM_ACTION_SIZE)
|
|
19
|
+
return false; // not a room action
|
|
20
|
+
const data = JSON.parse(raw);
|
|
17
21
|
if (data.action === "subscribe" && typeof data.room === "string") {
|
|
18
22
|
if (onSubscribe && !(await onSubscribe(ws, data.room))) {
|
|
19
23
|
ws.send(JSON.stringify({ event: "subscribe_denied", room: data.room }));
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
|
|
1
|
+
import { timingSafeEqual } from "../lib/crypto";
|
|
3
2
|
export const bearerAuth = async (c, next) => {
|
|
3
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
4
|
+
const validToken = isProd ? process.env.BEARER_TOKEN_PROD : process.env.BEARER_TOKEN_DEV;
|
|
4
5
|
const header = c.req.header("Authorization");
|
|
5
6
|
const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
6
|
-
if (!token || token
|
|
7
|
+
if (!token || !validToken || !timingSafeEqual(token, validToken)) {
|
|
7
8
|
return c.json({ error: "Unauthorized" }, 401);
|
|
8
9
|
}
|
|
9
10
|
await next();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getClientIp } from "../lib/clientIp";
|
|
1
2
|
// ---------------------------------------------------------------------------
|
|
2
3
|
// CIDR helpers (IPv4 only; IPv6 exact-match supported)
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
@@ -40,8 +41,7 @@ export const botProtection = ({ blockList = [], }) => {
|
|
|
40
41
|
if (blockList.length === 0)
|
|
41
42
|
return (_c, next) => next();
|
|
42
43
|
return async (c, next) => {
|
|
43
|
-
const
|
|
44
|
-
const ip = raw.split(",")[0]?.trim() ?? "unknown";
|
|
44
|
+
const ip = getClientIp(c);
|
|
45
45
|
if (ip !== "unknown" && isBlocked(ip, blockList)) {
|
|
46
46
|
return c.json({ error: "Forbidden" }, 403);
|
|
47
47
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MiddlewareHandler } from "hono";
|
|
2
2
|
import type { AppEnv } from "../lib/context";
|
|
3
|
+
export declare function getCacheModel(): import("mongoose").Model<any, {}, {}, {}, any, any, any>;
|
|
3
4
|
type CacheStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
4
5
|
export declare const setCacheStore: (store: CacheStore) => void;
|
|
5
6
|
export declare const bustCache: (key: string) => Promise<void>;
|
|
@@ -3,7 +3,7 @@ import { getAppName } from "../lib/appConfig";
|
|
|
3
3
|
import { appConnection, mongoose } from "../lib/mongo";
|
|
4
4
|
import { isSqliteReady, sqliteGetCache, sqliteSetCache, sqliteDelCache, sqliteDelCachePattern } from "../adapters/sqliteAuth";
|
|
5
5
|
import { memoryGetCache, memorySetCache, memoryDelCache, memoryDelCachePattern } from "../adapters/memoryAuth";
|
|
6
|
-
function getCacheModel() {
|
|
6
|
+
export function getCacheModel() {
|
|
7
7
|
if (appConnection.models["CacheEntry"])
|
|
8
8
|
return appConnection.models["CacheEntry"];
|
|
9
9
|
const { Schema } = mongoose;
|
|
@@ -130,6 +130,14 @@ export const bustCachePattern = async (pattern) => {
|
|
|
130
130
|
const fullPattern = `cache:${getAppName()}:${pattern}`;
|
|
131
131
|
await Promise.all([storeDelPattern("redis", fullPattern), storeDelPattern("mongo", fullPattern), storeDelPattern("sqlite", fullPattern), storeDelPattern("memory", fullPattern)]);
|
|
132
132
|
};
|
|
133
|
+
/** Headers that must never be cached — storing these can cause session fixation or auth bypass. */
|
|
134
|
+
const UNCACHEABLE_HEADERS = new Set([
|
|
135
|
+
"set-cookie",
|
|
136
|
+
"www-authenticate",
|
|
137
|
+
"authorization",
|
|
138
|
+
"x-csrf-token",
|
|
139
|
+
"proxy-authenticate",
|
|
140
|
+
]);
|
|
133
141
|
export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
|
|
134
142
|
return async (c, next) => {
|
|
135
143
|
const appName = getAppName();
|
|
@@ -151,7 +159,11 @@ export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }) => {
|
|
|
151
159
|
if (res.status >= 200 && res.status < 300) {
|
|
152
160
|
const body = await res.text();
|
|
153
161
|
const headers = {};
|
|
154
|
-
res.headers.forEach((value, name) => {
|
|
162
|
+
res.headers.forEach((value, name) => {
|
|
163
|
+
if (!UNCACHEABLE_HEADERS.has(name.toLowerCase())) {
|
|
164
|
+
headers[name] = value;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
155
167
|
await storeSet(store, cacheKey, JSON.stringify({ status: res.status, headers, body }), ttl);
|
|
156
168
|
c.res = new Response(body, {
|
|
157
169
|
status: res.status,
|
package/dist/middleware/cors.js
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
+
let _allowedOrigins = "*";
|
|
2
|
+
/** Configure the allowed CORS origins. Call once at startup. */
|
|
3
|
+
export const setCorsOrigins = (origins) => { _allowedOrigins = origins; };
|
|
1
4
|
export const cors = async (req, next) => {
|
|
5
|
+
const origin = req.headers.get("Origin");
|
|
6
|
+
const headers = corsHeaders(origin);
|
|
2
7
|
if (req.method === "OPTIONS") {
|
|
3
|
-
return new Response(null, { status: 204, headers
|
|
8
|
+
return new Response(null, { status: 204, headers });
|
|
4
9
|
}
|
|
5
10
|
const res = await next(req);
|
|
6
|
-
const
|
|
7
|
-
for (const [k, v] of Object.entries(
|
|
8
|
-
|
|
9
|
-
return new Response(res.body, { status: res.status, headers });
|
|
11
|
+
const resHeaders = new Headers(res.headers);
|
|
12
|
+
for (const [k, v] of Object.entries(headers))
|
|
13
|
+
resHeaders.set(k, v);
|
|
14
|
+
return new Response(res.body, { status: res.status, headers: resHeaders });
|
|
10
15
|
};
|
|
11
|
-
|
|
16
|
+
function corsHeaders(requestOrigin) {
|
|
17
|
+
let allowOrigin;
|
|
18
|
+
if (_allowedOrigins === "*") {
|
|
19
|
+
allowOrigin = "*";
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const origins = Array.isArray(_allowedOrigins) ? _allowedOrigins : [_allowedOrigins];
|
|
23
|
+
allowOrigin = requestOrigin && origins.includes(requestOrigin) ? requestOrigin : origins[0];
|
|
24
|
+
}
|
|
12
25
|
return {
|
|
13
|
-
"Access-Control-Allow-Origin":
|
|
26
|
+
"Access-Control-Allow-Origin": allowOrigin,
|
|
14
27
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
15
28
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
29
|
+
...(allowOrigin !== "*" ? { Vary: "Origin" } : {}),
|
|
16
30
|
};
|
|
17
|
-
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { setCookie, deleteCookie } from "hono/cookie";
|
|
3
|
+
import type { AppEnv } from "../lib/context";
|
|
4
|
+
export interface CsrfMiddlewareOptions {
|
|
5
|
+
exemptPaths?: string[];
|
|
6
|
+
checkOrigin?: boolean;
|
|
7
|
+
allowedOrigins?: string | string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Refreshes the CSRF token cookie — call on login/register to prevent
|
|
11
|
+
* session fixation-adjacent attacks.
|
|
12
|
+
*/
|
|
13
|
+
export declare function refreshCsrfToken(c: Parameters<typeof setCookie>[0]): void;
|
|
14
|
+
/**
|
|
15
|
+
* Clears the CSRF token cookie — call on logout.
|
|
16
|
+
*/
|
|
17
|
+
export declare function clearCsrfToken(c: Parameters<typeof deleteCookie>[0]): void;
|
|
18
|
+
export declare const csrfProtection: (options?: CsrfMiddlewareOptions) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
2
|
+
import { timingSafeEqual } from "../lib/crypto";
|
|
3
|
+
import { COOKIE_TOKEN, COOKIE_CSRF_TOKEN, HEADER_CSRF_TOKEN } from "../lib/constants";
|
|
4
|
+
import { createHmac, randomBytes } from "crypto";
|
|
5
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
6
|
+
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
+
function getJwtSecret() {
|
|
8
|
+
const secret = isProd ? process.env.JWT_SECRET_PROD : process.env.JWT_SECRET_DEV;
|
|
9
|
+
if (!secret)
|
|
10
|
+
throw new Error("CSRF middleware requires JWT_SECRET_DEV/JWT_SECRET_PROD to be set");
|
|
11
|
+
return secret;
|
|
12
|
+
}
|
|
13
|
+
function generateCsrfToken(secret) {
|
|
14
|
+
const token = randomBytes(32).toString("hex");
|
|
15
|
+
const sig = createHmac("sha256", secret).update(token).digest("hex");
|
|
16
|
+
return `${token}.${sig}`;
|
|
17
|
+
}
|
|
18
|
+
function verifyCsrfSignature(cookieValue, secret) {
|
|
19
|
+
const dotIdx = cookieValue.indexOf(".");
|
|
20
|
+
if (dotIdx === -1)
|
|
21
|
+
return false;
|
|
22
|
+
const token = cookieValue.substring(0, dotIdx);
|
|
23
|
+
const sig = cookieValue.substring(dotIdx + 1);
|
|
24
|
+
const expected = createHmac("sha256", secret).update(token).digest("hex");
|
|
25
|
+
return timingSafeEqual(sig, expected);
|
|
26
|
+
}
|
|
27
|
+
const csrfCookieOptions = {
|
|
28
|
+
httpOnly: false,
|
|
29
|
+
secure: isProd,
|
|
30
|
+
sameSite: "Lax",
|
|
31
|
+
path: "/",
|
|
32
|
+
maxAge: 60 * 60 * 24 * 365, // 1 year — tied to browser, not session
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Refreshes the CSRF token cookie — call on login/register to prevent
|
|
36
|
+
* session fixation-adjacent attacks.
|
|
37
|
+
*/
|
|
38
|
+
export function refreshCsrfToken(c) {
|
|
39
|
+
const secret = getJwtSecret();
|
|
40
|
+
const token = generateCsrfToken(secret);
|
|
41
|
+
setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Clears the CSRF token cookie — call on logout.
|
|
45
|
+
*/
|
|
46
|
+
export function clearCsrfToken(c) {
|
|
47
|
+
deleteCookie(c, COOKIE_CSRF_TOKEN, { path: "/" });
|
|
48
|
+
}
|
|
49
|
+
export const csrfProtection = (options = {}) => {
|
|
50
|
+
const { exemptPaths = [], checkOrigin = true, allowedOrigins } = options;
|
|
51
|
+
// Normalize allowed origins for origin validation
|
|
52
|
+
const originSet = new Set();
|
|
53
|
+
if (allowedOrigins) {
|
|
54
|
+
const origins = Array.isArray(allowedOrigins) ? allowedOrigins : [allowedOrigins];
|
|
55
|
+
for (const o of origins) {
|
|
56
|
+
if (o !== "*")
|
|
57
|
+
originSet.add(o.replace(/\/$/, ""));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return async (c, next) => {
|
|
61
|
+
const secret = getJwtSecret();
|
|
62
|
+
// Set CSRF cookie on every response if not already present
|
|
63
|
+
const existingCsrf = getCookie(c, COOKIE_CSRF_TOKEN);
|
|
64
|
+
if (!existingCsrf) {
|
|
65
|
+
const token = generateCsrfToken(secret);
|
|
66
|
+
setCookie(c, COOKIE_CSRF_TOKEN, token, csrfCookieOptions);
|
|
67
|
+
}
|
|
68
|
+
// Only validate state-changing methods
|
|
69
|
+
if (!STATE_CHANGING_METHODS.has(c.req.method)) {
|
|
70
|
+
return next();
|
|
71
|
+
}
|
|
72
|
+
// Skip if no auth cookie present — not vulnerable to CSRF
|
|
73
|
+
const authCookie = getCookie(c, COOKIE_TOKEN);
|
|
74
|
+
if (!authCookie) {
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
// Skip exempt paths
|
|
78
|
+
const path = c.req.path;
|
|
79
|
+
for (const exempt of exemptPaths) {
|
|
80
|
+
if (exempt.endsWith("*")) {
|
|
81
|
+
if (path.startsWith(exempt.slice(0, -1)))
|
|
82
|
+
return next();
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (path === exempt)
|
|
86
|
+
return next();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Origin validation (secondary layer)
|
|
90
|
+
if (checkOrigin && originSet.size > 0) {
|
|
91
|
+
const origin = c.req.header("origin");
|
|
92
|
+
if (origin) {
|
|
93
|
+
const normalized = origin.replace(/\/$/, "");
|
|
94
|
+
if (!originSet.has(normalized)) {
|
|
95
|
+
return c.json({ error: "CSRF origin mismatch" }, 403);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Double submit cookie validation
|
|
100
|
+
const csrfCookie = getCookie(c, COOKIE_CSRF_TOKEN);
|
|
101
|
+
const csrfHeader = c.req.header(HEADER_CSRF_TOKEN);
|
|
102
|
+
if (!csrfCookie || !csrfHeader) {
|
|
103
|
+
return c.json({ error: "CSRF token missing" }, 403);
|
|
104
|
+
}
|
|
105
|
+
// Verify the cookie's HMAC signature (prevents cookie injection)
|
|
106
|
+
if (!verifyCsrfSignature(csrfCookie, secret)) {
|
|
107
|
+
return c.json({ error: "CSRF token invalid" }, 403);
|
|
108
|
+
}
|
|
109
|
+
// Compare header value to cookie value
|
|
110
|
+
if (!timingSafeEqual(csrfHeader, csrfCookie)) {
|
|
111
|
+
return c.json({ error: "CSRF token mismatch" }, 403);
|
|
112
|
+
}
|
|
113
|
+
return next();
|
|
114
|
+
};
|
|
115
|
+
};
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { trackAttempt } from "../lib/authRateLimit";
|
|
2
2
|
import { buildFingerprint } from "../lib/fingerprint";
|
|
3
|
+
import { getClientIp } from "../lib/clientIp";
|
|
3
4
|
export const rateLimit = ({ windowMs, max, fingerprintLimit = false, }) => {
|
|
4
5
|
const opts = { windowMs, max };
|
|
5
6
|
return async (c, next) => {
|
|
6
|
-
|
|
7
|
-
const raw = c.req.header("x-forwarded-for") ?? "";
|
|
8
|
-
const ip = raw.split(",")[0]?.trim() || "unknown";
|
|
7
|
+
const ip = getClientIp(c);
|
|
9
8
|
// Per-tenant namespacing: each tenant gets independent rate limit buckets
|
|
10
9
|
const tenantId = c.get("tenantId");
|
|
11
10
|
const prefix = tenantId ? `t:${tenantId}:` : "";
|
|
@@ -16,6 +16,15 @@ interface IAuthUser {
|
|
|
16
16
|
recoveryCodes?: string[];
|
|
17
17
|
/** MFA methods enabled for this user (e.g., ["totp"], ["emailOtp"], ["totp", "emailOtp"]). */
|
|
18
18
|
mfaMethods?: string[];
|
|
19
|
+
/** WebAuthn credentials (security keys / platform authenticators). */
|
|
20
|
+
webauthnCredentials?: Array<{
|
|
21
|
+
credentialId: string;
|
|
22
|
+
publicKey: string;
|
|
23
|
+
signCount: number;
|
|
24
|
+
transports?: string[];
|
|
25
|
+
name?: string;
|
|
26
|
+
createdAt: Date;
|
|
27
|
+
}>;
|
|
19
28
|
}
|
|
20
29
|
type AuthUserDocument = IAuthUser & Document;
|
|
21
30
|
export declare const AuthUser: Model<AuthUserDocument, {}, {}, {}, Document<unknown, {}, AuthUserDocument, {}, import("mongoose").DefaultSchemaOptions> & IAuthUser & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
|
package/dist/models/AuthUser.js
CHANGED
|
@@ -22,6 +22,15 @@ function getAuthUser() {
|
|
|
22
22
|
recoveryCodes: [{ type: String }],
|
|
23
23
|
/** MFA methods enabled for this user. */
|
|
24
24
|
mfaMethods: [{ type: String }],
|
|
25
|
+
/** WebAuthn credentials (security keys / platform authenticators). */
|
|
26
|
+
webauthnCredentials: [{
|
|
27
|
+
credentialId: { type: String, required: true },
|
|
28
|
+
publicKey: { type: String, required: true },
|
|
29
|
+
signCount: { type: Number, required: true, default: 0 },
|
|
30
|
+
transports: [{ type: String }],
|
|
31
|
+
name: { type: String },
|
|
32
|
+
createdAt: { type: Date, default: Date.now },
|
|
33
|
+
}],
|
|
25
34
|
}, { timestamps: true });
|
|
26
35
|
schema.index({ providerIds: 1 });
|
|
27
36
|
_AuthUser = authConnection.model("AuthUser", schema);
|
package/dist/routes/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createRoute, withSecurity } from "../lib/createRoute";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
4
|
import * as AuthService from "../services/auth";
|
|
5
|
-
import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
|
|
5
|
+
import { makeRegisterSchema, makeLoginSchema, resetPasswordSchema } from "../schemas/auth";
|
|
6
6
|
import { COOKIE_TOKEN, HEADER_USER_TOKEN, COOKIE_REFRESH_TOKEN, HEADER_REFRESH_TOKEN } from "../lib/constants";
|
|
7
7
|
import { userAuth } from "../middleware/userAuth";
|
|
8
8
|
import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
|
|
@@ -10,8 +10,10 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
10
10
|
import { createRouter } from "../lib/context";
|
|
11
11
|
import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
|
|
12
12
|
import { createResetToken, consumeResetToken } from "../lib/resetPassword";
|
|
13
|
-
import { getRefreshTokenExpiry, getAccessTokenExpiry } from "../lib/appConfig";
|
|
13
|
+
import { getRefreshTokenExpiry, getAccessTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
|
|
14
|
+
import { refreshCsrfToken, clearCsrfToken } from "../middleware/csrf";
|
|
14
15
|
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
16
|
+
import { getClientIp } from "../lib/clientIp";
|
|
15
17
|
const isProd = process.env.NODE_ENV === "production";
|
|
16
18
|
const TokenResponse = z.object({
|
|
17
19
|
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
@@ -33,7 +35,6 @@ const cookieOptions = (maxAge) => ({
|
|
|
33
35
|
path: "/",
|
|
34
36
|
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
35
37
|
});
|
|
36
|
-
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
37
38
|
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
38
39
|
const router = createRouter();
|
|
39
40
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
@@ -61,7 +62,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
61
62
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many registration attempts from this IP. Try again later." },
|
|
62
63
|
},
|
|
63
64
|
}), async (c) => {
|
|
64
|
-
const ip =
|
|
65
|
+
const ip = getClientIp(c);
|
|
65
66
|
if (await trackAttempt(`register:${ip}`, registerOpts)) {
|
|
66
67
|
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
67
68
|
}
|
|
@@ -76,6 +77,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
76
77
|
if (result.refreshToken) {
|
|
77
78
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
78
79
|
}
|
|
80
|
+
if (getCsrfEnabled())
|
|
81
|
+
refreshCsrfToken(c);
|
|
79
82
|
return c.json(result, 201);
|
|
80
83
|
});
|
|
81
84
|
router.openapi(createRoute({
|
|
@@ -99,7 +102,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
99
102
|
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
100
103
|
}
|
|
101
104
|
const metadata = {
|
|
102
|
-
ipAddress:
|
|
105
|
+
ipAddress: getClientIp(c),
|
|
103
106
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
104
107
|
};
|
|
105
108
|
try {
|
|
@@ -110,6 +113,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
110
113
|
if (result.refreshToken) {
|
|
111
114
|
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
112
115
|
}
|
|
116
|
+
if (getCsrfEnabled())
|
|
117
|
+
refreshCsrfToken(c);
|
|
113
118
|
}
|
|
114
119
|
return c.json(result, 200);
|
|
115
120
|
}
|
|
@@ -255,6 +260,8 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
255
260
|
await AuthService.logout(token);
|
|
256
261
|
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
257
262
|
deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
|
|
263
|
+
if (getCsrfEnabled())
|
|
264
|
+
clearCsrfToken(c);
|
|
258
265
|
return c.json({ message: "Logged out" }, 200);
|
|
259
266
|
});
|
|
260
267
|
// Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
|
|
@@ -272,7 +279,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
272
279
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many verification attempts from this IP. Try again later." },
|
|
273
280
|
},
|
|
274
281
|
}), async (c) => {
|
|
275
|
-
const ip = c
|
|
282
|
+
const ip = getClientIp(c);
|
|
276
283
|
if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
|
|
277
284
|
return c.json({ error: "Too many verification attempts. Try again later." }, 429);
|
|
278
285
|
}
|
|
@@ -341,7 +348,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
341
348
|
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts from this IP or for this email address. Try again later." },
|
|
342
349
|
},
|
|
343
350
|
}), async (c) => {
|
|
344
|
-
const ip =
|
|
351
|
+
const ip = getClientIp(c);
|
|
345
352
|
const { email } = c.req.valid("json");
|
|
346
353
|
// Rate-limit by both IP and email to prevent distributed email-bombing
|
|
347
354
|
const ipLimited = await trackAttempt(`forgot:ip:${ip}`, forgotOpts);
|
|
@@ -379,7 +386,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
379
386
|
"application/json": {
|
|
380
387
|
schema: z.object({
|
|
381
388
|
token: z.string().describe("Single-use reset token received via email."),
|
|
382
|
-
password:
|
|
389
|
+
password: resetPasswordSchema().describe("New password."),
|
|
383
390
|
}),
|
|
384
391
|
},
|
|
385
392
|
},
|
|
@@ -393,7 +400,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
393
400
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support setPassword." },
|
|
394
401
|
},
|
|
395
402
|
}), async (c) => {
|
|
396
|
-
const ip =
|
|
403
|
+
const ip = getClientIp(c);
|
|
397
404
|
if (await trackAttempt(`reset:${ip}`, resetOpts)) {
|
|
398
405
|
return c.json({ error: "Too many attempts. Try again later." }, 429);
|
|
399
406
|
}
|
|
@@ -444,8 +451,13 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
444
451
|
responses: {
|
|
445
452
|
200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
|
|
446
453
|
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
|
|
454
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many refresh attempts. Try again later." },
|
|
447
455
|
},
|
|
448
456
|
}), async (c) => {
|
|
457
|
+
const ip = getClientIp(c);
|
|
458
|
+
if (await trackAttempt(`refresh:ip:${ip}`, { max: 30, windowMs: 60_000 })) {
|
|
459
|
+
return c.json({ error: "Too many refresh attempts. Try again later." }, 429);
|
|
460
|
+
}
|
|
449
461
|
const body = c.req.valid("json");
|
|
450
462
|
const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
|
|
451
463
|
if (!rt) {
|
package/dist/routes/mfa.d.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import type { AuthRateLimitConfig } from "../app";
|
|
2
|
+
export interface MfaRouterOptions {
|
|
3
|
+
rateLimit?: AuthRateLimitConfig;
|
|
4
|
+
}
|
|
5
|
+
export declare const createMfaRouter: ({ rateLimit }?: MfaRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|