@lastshotlabs/bunshot 0.0.1
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/CLAUDE.md +102 -0
- package/README.md +1458 -0
- package/bun.lock +170 -0
- package/package.json +47 -0
- package/src/adapters/memoryAuth.ts +240 -0
- package/src/adapters/mongoAuth.ts +91 -0
- package/src/adapters/sqliteAuth.ts +320 -0
- package/src/app.ts +368 -0
- package/src/cli.ts +265 -0
- package/src/index.ts +52 -0
- package/src/lib/HttpError.ts +5 -0
- package/src/lib/appConfig.ts +29 -0
- package/src/lib/authAdapter.ts +46 -0
- package/src/lib/authRateLimit.ts +104 -0
- package/src/lib/constants.ts +2 -0
- package/src/lib/context.ts +17 -0
- package/src/lib/emailVerification.ts +105 -0
- package/src/lib/fingerprint.ts +43 -0
- package/src/lib/jwt.ts +17 -0
- package/src/lib/logger.ts +9 -0
- package/src/lib/mongo.ts +70 -0
- package/src/lib/oauth.ts +114 -0
- package/src/lib/queue.ts +18 -0
- package/src/lib/redis.ts +45 -0
- package/src/lib/roles.ts +23 -0
- package/src/lib/session.ts +91 -0
- package/src/lib/validate.ts +14 -0
- package/src/lib/ws.ts +82 -0
- package/src/middleware/bearerAuth.ts +15 -0
- package/src/middleware/botProtection.ts +73 -0
- package/src/middleware/cacheResponse.ts +189 -0
- package/src/middleware/cors.ts +19 -0
- package/src/middleware/errorHandler.ts +14 -0
- package/src/middleware/identify.ts +36 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/logger.ts +9 -0
- package/src/middleware/rateLimit.ts +37 -0
- package/src/middleware/requireRole.ts +42 -0
- package/src/middleware/requireVerifiedEmail.ts +31 -0
- package/src/middleware/userAuth.ts +9 -0
- package/src/models/AuthUser.ts +17 -0
- package/src/routes/auth.ts +245 -0
- package/src/routes/health.ts +27 -0
- package/src/routes/home.ts +21 -0
- package/src/routes/oauth.ts +174 -0
- package/src/schemas/auth.ts +14 -0
- package/src/server.ts +91 -0
- package/src/services/auth.ts +59 -0
- package/src/ws/index.ts +42 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { getRedis } from "@lib/redis";
|
|
3
|
+
import { getAppName } from "@lib/appConfig";
|
|
4
|
+
import type { AppEnv } from "@lib/context";
|
|
5
|
+
import { appConnection } from "@lib/mongo";
|
|
6
|
+
import { Schema } from "mongoose";
|
|
7
|
+
import { isSqliteReady, sqliteGetCache, sqliteSetCache, sqliteDelCache, sqliteDelCachePattern } from "../adapters/sqliteAuth";
|
|
8
|
+
import { memoryGetCache, memorySetCache, memoryDelCache, memoryDelCachePattern } from "../adapters/memoryAuth";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Mongo cache model (lazy — only registered when "mongo" store is used)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
interface CacheDoc {
|
|
15
|
+
key: string;
|
|
16
|
+
value: string;
|
|
17
|
+
expiresAt?: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const cacheSchema = new Schema<CacheDoc>(
|
|
21
|
+
{
|
|
22
|
+
key: { type: String, required: true, unique: true },
|
|
23
|
+
value: { type: String, required: true },
|
|
24
|
+
expiresAt: { type: Date, index: { expireAfterSeconds: 0 } },
|
|
25
|
+
},
|
|
26
|
+
{ collection: "cache_entries" }
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function getCacheModel() {
|
|
30
|
+
return appConnection.models["CacheEntry"] ??
|
|
31
|
+
appConnection.model<CacheDoc>("CacheEntry", cacheSchema);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isMongoReady(): boolean {
|
|
35
|
+
return appConnection.readyState === 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRedisReady(): boolean {
|
|
39
|
+
try { getRedis(); return true; } catch { return false; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Shared payload type
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
type CachePayload = { status: number; headers: Record<string, string>; body: string };
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Store adapters
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
type CacheStore = "redis" | "mongo" | "sqlite" | "memory";
|
|
53
|
+
|
|
54
|
+
let _defaultCacheStore: CacheStore = "redis";
|
|
55
|
+
export const setCacheStore = (store: CacheStore) => { _defaultCacheStore = store; };
|
|
56
|
+
|
|
57
|
+
async function storeGet(store: CacheStore, cacheKey: string): Promise<string | null> {
|
|
58
|
+
if (store === "memory") return memoryGetCache(cacheKey);
|
|
59
|
+
if (store === "sqlite") {
|
|
60
|
+
if (!isSqliteReady()) throw new Error(`cacheResponse: store is "sqlite" but SQLite is not initialized. Call setSqliteDb(path) or pass sqliteDb to createServer.`);
|
|
61
|
+
return sqliteGetCache(cacheKey);
|
|
62
|
+
}
|
|
63
|
+
if (store === "mongo") {
|
|
64
|
+
if (!isMongoReady())
|
|
65
|
+
throw new Error(`cacheResponse: store is "mongo" but appConnection is not connected. Ensure connectMongo() or connectAppMongo() is called before handling requests.`);
|
|
66
|
+
const doc = await getCacheModel().findOne({ key: cacheKey }, "value").lean();
|
|
67
|
+
return doc ? doc.value : null;
|
|
68
|
+
}
|
|
69
|
+
return getRedis().get(cacheKey);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function storeSet(store: CacheStore, cacheKey: string, value: string, ttl?: number): Promise<void> {
|
|
73
|
+
if (store === "memory") { memorySetCache(cacheKey, value, ttl); return; }
|
|
74
|
+
if (store === "sqlite") {
|
|
75
|
+
if (!isSqliteReady()) throw new Error(`cacheResponse: store is "sqlite" but SQLite is not initialized. Call setSqliteDb(path) or pass sqliteDb to createServer.`);
|
|
76
|
+
sqliteSetCache(cacheKey, value, ttl);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (store === "mongo") {
|
|
80
|
+
if (!isMongoReady())
|
|
81
|
+
throw new Error(`cacheResponse: store is "mongo" but appConnection is not connected. Ensure connectMongo() or connectAppMongo() is called before handling requests.`);
|
|
82
|
+
const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : undefined;
|
|
83
|
+
await getCacheModel().updateOne(
|
|
84
|
+
{ key: cacheKey },
|
|
85
|
+
{ $set: { value, ...(expiresAt ? { expiresAt } : {}) } },
|
|
86
|
+
{ upsert: true }
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (ttl) {
|
|
91
|
+
await getRedis().setex(cacheKey, ttl, value);
|
|
92
|
+
} else {
|
|
93
|
+
await getRedis().set(cacheKey, value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function storeDel(store: CacheStore, cacheKey: string): Promise<void> {
|
|
98
|
+
if (store === "memory") { memoryDelCache(cacheKey); return; }
|
|
99
|
+
if (store === "sqlite") {
|
|
100
|
+
if (!isSqliteReady()) return;
|
|
101
|
+
sqliteDelCache(cacheKey);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (store === "mongo") {
|
|
105
|
+
if (!isMongoReady()) return;
|
|
106
|
+
await getCacheModel().deleteOne({ key: cacheKey });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!isRedisReady()) return;
|
|
110
|
+
await getRedis().del(cacheKey);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function storeDelPattern(store: CacheStore, fullPattern: string): Promise<void> {
|
|
114
|
+
if (store === "memory") { memoryDelCachePattern(fullPattern); return; }
|
|
115
|
+
if (store === "sqlite") {
|
|
116
|
+
if (!isSqliteReady()) return;
|
|
117
|
+
sqliteDelCachePattern(fullPattern);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (store === "mongo") {
|
|
121
|
+
if (!isMongoReady()) return;
|
|
122
|
+
const regex = new RegExp("^" + fullPattern.replace(/\*/g, ".*") + "$");
|
|
123
|
+
await getCacheModel().deleteMany({ key: regex });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!isRedisReady()) return;
|
|
127
|
+
const redis = getRedis();
|
|
128
|
+
let cursor = "0";
|
|
129
|
+
do {
|
|
130
|
+
const [next, keys] = await redis.scan(cursor, "MATCH", fullPattern, "COUNT", 100);
|
|
131
|
+
cursor = next;
|
|
132
|
+
if (keys.length > 0) await redis.del(...keys);
|
|
133
|
+
} while (cursor !== "0");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Public API
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
export const bustCache = async (key: string) => {
|
|
141
|
+
const cacheKey = `cache:${getAppName()}:${key}`;
|
|
142
|
+
await Promise.all([storeDel("redis", cacheKey), storeDel("mongo", cacheKey), storeDel("sqlite", cacheKey), storeDel("memory", cacheKey)]);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const bustCachePattern = async (pattern: string) => {
|
|
146
|
+
const fullPattern = `cache:${getAppName()}:${pattern}`;
|
|
147
|
+
await Promise.all([storeDelPattern("redis", fullPattern), storeDelPattern("mongo", fullPattern), storeDelPattern("sqlite", fullPattern), storeDelPattern("memory", fullPattern)]);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
type KeyFn = (c: Parameters<MiddlewareHandler<AppEnv>>[0]) => string;
|
|
151
|
+
|
|
152
|
+
interface CacheOptions {
|
|
153
|
+
ttl?: number; // seconds — omit for indefinite
|
|
154
|
+
key: string | KeyFn;
|
|
155
|
+
store?: CacheStore; // default: inherits from db.cache config (setCacheStore), falls back to "redis"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const cacheResponse = ({ ttl, key, store = _defaultCacheStore }: CacheOptions): MiddlewareHandler<AppEnv> => {
|
|
159
|
+
return async (c, next) => {
|
|
160
|
+
const appName = getAppName();
|
|
161
|
+
const rawKey = typeof key === "function" ? key(c) : key;
|
|
162
|
+
const cacheKey = `cache:${appName}:${rawKey}`;
|
|
163
|
+
|
|
164
|
+
const cached = await storeGet(store, cacheKey);
|
|
165
|
+
if (cached) {
|
|
166
|
+
const { status, headers, body } = JSON.parse(cached) as CachePayload;
|
|
167
|
+
return new Response(body, {
|
|
168
|
+
status,
|
|
169
|
+
headers: { ...headers, "x-cache": "HIT" },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await next();
|
|
174
|
+
|
|
175
|
+
const res = c.res;
|
|
176
|
+
if (res.status >= 200 && res.status < 300) {
|
|
177
|
+
const body = await res.text();
|
|
178
|
+
const headers: Record<string, string> = {};
|
|
179
|
+
res.headers.forEach((value, name) => { headers[name] = value; });
|
|
180
|
+
|
|
181
|
+
await storeSet(store, cacheKey, JSON.stringify({ status: res.status, headers, body }), ttl);
|
|
182
|
+
|
|
183
|
+
c.res = new Response(body, {
|
|
184
|
+
status: res.status,
|
|
185
|
+
headers: { ...headers, "x-cache": "MISS" },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Middleware } from ".";
|
|
2
|
+
|
|
3
|
+
export const cors: Middleware = async (req, next) => {
|
|
4
|
+
if (req.method === "OPTIONS") {
|
|
5
|
+
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
6
|
+
}
|
|
7
|
+
const res = await next(req);
|
|
8
|
+
const headers = new Headers(res.headers);
|
|
9
|
+
for (const [k, v] of Object.entries(corsHeaders())) headers.set(k, v);
|
|
10
|
+
return new Response(res.body, { status: res.status, headers });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const corsHeaders = () => {
|
|
14
|
+
return {
|
|
15
|
+
"Access-Control-Allow-Origin": "*",
|
|
16
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
17
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Middleware } from ".";
|
|
2
|
+
import { HttpError } from "@lib/HttpError";
|
|
3
|
+
|
|
4
|
+
export const errorHandler: Middleware = async (req, next) => {
|
|
5
|
+
try {
|
|
6
|
+
return await next(req);
|
|
7
|
+
} catch (err) {
|
|
8
|
+
console.error(err);
|
|
9
|
+
if (err instanceof HttpError) {
|
|
10
|
+
return Response.json({ error: err.message }, { status: err.status });
|
|
11
|
+
}
|
|
12
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
13
|
+
}
|
|
14
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { getCookie } from "hono/cookie";
|
|
3
|
+
import type { AppEnv } from "@lib/context";
|
|
4
|
+
import { verifyToken } from "@lib/jwt";
|
|
5
|
+
import { getSession } from "@lib/session";
|
|
6
|
+
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "@lib/constants";
|
|
7
|
+
import { log } from "@lib/logger";
|
|
8
|
+
|
|
9
|
+
export const identify: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
10
|
+
c.set("authUserId", null);
|
|
11
|
+
c.set("roles", null);
|
|
12
|
+
|
|
13
|
+
// cookie for browsers, x-user-token header for non-browser clients
|
|
14
|
+
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
15
|
+
log(`[identify] token=${token ? "present" : "absent"}`);
|
|
16
|
+
|
|
17
|
+
if (token) {
|
|
18
|
+
try {
|
|
19
|
+
const payload = await verifyToken(token);
|
|
20
|
+
const stored = await getSession(payload.sub!);
|
|
21
|
+
log(`[identify] token for authUserId=${payload.sub} verified, checking session...`);
|
|
22
|
+
if (stored === token) {
|
|
23
|
+
c.set("authUserId", payload.sub!);
|
|
24
|
+
log(`[identify] authUserId=${payload.sub}`);
|
|
25
|
+
} else {
|
|
26
|
+
log("[identify] token/session mismatch — unauthenticated");
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
log("[identify] invalid token — unauthenticated");
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
log("[identify] no token — unauthenticated");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await next();
|
|
36
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Handler = (req: Request) => Response | Promise<Response>;
|
|
2
|
+
export type Middleware = (req: Request, next: Handler) => Response | Promise<Response>;
|
|
3
|
+
|
|
4
|
+
export const applyMiddleware = (handler: Handler, ...middleware: Middleware[]): Handler =>
|
|
5
|
+
middleware.reduceRight<Handler>(
|
|
6
|
+
(next, mw) => (req) => mw(req, next),
|
|
7
|
+
handler
|
|
8
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Middleware } from ".";
|
|
2
|
+
|
|
3
|
+
export const logger: Middleware = async (req, next) => {
|
|
4
|
+
const start = performance.now();
|
|
5
|
+
const res = await next(req);
|
|
6
|
+
const ms = (performance.now() - start).toFixed(2);
|
|
7
|
+
console.log(`${req.method} ${new URL(req.url).pathname} ${res.status} ${ms}ms`);
|
|
8
|
+
return res;
|
|
9
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import { trackAttempt } from "@lib/authRateLimit";
|
|
3
|
+
import { buildFingerprint } from "@lib/fingerprint";
|
|
4
|
+
|
|
5
|
+
export interface RateLimitOptions {
|
|
6
|
+
windowMs: number;
|
|
7
|
+
max: number;
|
|
8
|
+
/** Also rate-limit by HTTP fingerprint in addition to IP. Default: false */
|
|
9
|
+
fingerprintLimit?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const rateLimit = ({
|
|
13
|
+
windowMs,
|
|
14
|
+
max,
|
|
15
|
+
fingerprintLimit = false,
|
|
16
|
+
}: RateLimitOptions): MiddlewareHandler => {
|
|
17
|
+
const opts = { windowMs, max };
|
|
18
|
+
|
|
19
|
+
return async (c, next) => {
|
|
20
|
+
// Take the leftmost (client) IP from x-forwarded-for
|
|
21
|
+
const raw = c.req.header("x-forwarded-for") ?? "";
|
|
22
|
+
const ip = raw.split(",")[0]?.trim() || "unknown";
|
|
23
|
+
|
|
24
|
+
if (await trackAttempt(`ip:${ip}`, opts)) {
|
|
25
|
+
return c.json({ error: "Too Many Requests" }, 429);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (fingerprintLimit) {
|
|
29
|
+
const fp = await buildFingerprint(c.req.raw);
|
|
30
|
+
if (await trackAttempt(`fp:${fp}`, opts)) {
|
|
31
|
+
return c.json({ error: "Too Many Requests" }, 429);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
await next();
|
|
36
|
+
};
|
|
37
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "@lib/context";
|
|
3
|
+
import { getAuthAdapter } from "@lib/authAdapter";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware factory that enforces role-based access.
|
|
7
|
+
* Requires `identify` to have run first (authUserId must be set).
|
|
8
|
+
* Roles are fetched lazily on the first role-checked route and cached on the context.
|
|
9
|
+
*
|
|
10
|
+
* The adapter must implement `getRoles` for this to work.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Allow any authenticated user with the "admin" role
|
|
14
|
+
* app.get("/admin", userAuth, requireRole("admin"), handler)
|
|
15
|
+
*
|
|
16
|
+
* // Allow users with either "admin" or "moderator"
|
|
17
|
+
* app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
|
|
18
|
+
*/
|
|
19
|
+
export const requireRole = (...roles: string[]): MiddlewareHandler<AppEnv> => async (c, next) => {
|
|
20
|
+
const userId = c.get("authUserId");
|
|
21
|
+
if (!userId) {
|
|
22
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Lazy-fetch roles and cache on context so multiple requireRole calls in a chain only hit the adapter once
|
|
26
|
+
let userRoles = c.get("roles");
|
|
27
|
+
if (userRoles === null) {
|
|
28
|
+
const adapter = getAuthAdapter();
|
|
29
|
+
if (!adapter.getRoles) {
|
|
30
|
+
throw new Error("requireRole used but auth adapter does not implement getRoles");
|
|
31
|
+
}
|
|
32
|
+
userRoles = await adapter.getRoles(userId);
|
|
33
|
+
c.set("roles", userRoles);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasRole = roles.some((role) => userRoles!.includes(role));
|
|
37
|
+
if (!hasRole) {
|
|
38
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await next();
|
|
42
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "@lib/context";
|
|
3
|
+
import { getAuthAdapter } from "@lib/authAdapter";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware that blocks access for users whose email address has not been verified.
|
|
7
|
+
* Must run after `userAuth` (requires `authUserId` to be set on context).
|
|
8
|
+
*
|
|
9
|
+
* The adapter must implement `getEmailVerified` for this to work.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* router.use("/dashboard", userAuth, requireVerifiedEmail);
|
|
13
|
+
*/
|
|
14
|
+
export const requireVerifiedEmail: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
15
|
+
const userId = c.get("authUserId");
|
|
16
|
+
if (!userId) {
|
|
17
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const adapter = getAuthAdapter();
|
|
21
|
+
if (!adapter.getEmailVerified) {
|
|
22
|
+
throw new Error("requireVerifiedEmail used but auth adapter does not implement getEmailVerified");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const verified = await adapter.getEmailVerified(userId);
|
|
26
|
+
if (!verified) {
|
|
27
|
+
return c.json({ error: "Email not verified" }, 403);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await next();
|
|
31
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "@lib/context";
|
|
3
|
+
|
|
4
|
+
export const userAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|
5
|
+
if (!c.get("authUserId")) {
|
|
6
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
7
|
+
}
|
|
8
|
+
await next();
|
|
9
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import mongoose from "mongoose";
|
|
2
|
+
import { authConnection } from "@lib/mongo";
|
|
3
|
+
|
|
4
|
+
const AuthUserSchema = new mongoose.Schema({
|
|
5
|
+
email: { type: String, unique: true, sparse: true, lowercase: true },
|
|
6
|
+
password: { type: String },
|
|
7
|
+
/** Compound provider keys: ["google:123456", "apple:000111"] */
|
|
8
|
+
providerIds: [{ type: String }],
|
|
9
|
+
/** App-defined roles assigned to this user: ["admin", "editor", ...] */
|
|
10
|
+
roles: [{ type: String }],
|
|
11
|
+
/** Whether the user's email address has been verified. */
|
|
12
|
+
emailVerified: { type: Boolean, default: false },
|
|
13
|
+
}, { timestamps: true });
|
|
14
|
+
|
|
15
|
+
AuthUserSchema.index({ providerIds: 1 });
|
|
16
|
+
|
|
17
|
+
export const AuthUser = authConnection.model("AuthUser", AuthUserSchema);
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
|
+
import * as AuthService from "@services/auth";
|
|
5
|
+
import { makeRegisterSchema, makeLoginSchema } from "@schemas/auth";
|
|
6
|
+
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "@lib/constants";
|
|
7
|
+
import { userAuth } from "@middleware/userAuth";
|
|
8
|
+
import { isLimited, trackAttempt, bustAuthLimit } from "@lib/authRateLimit";
|
|
9
|
+
import { getAuthAdapter } from "@lib/authAdapter";
|
|
10
|
+
import { createRouter } from "@lib/context";
|
|
11
|
+
import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "@lib/emailVerification";
|
|
12
|
+
import type { PrimaryField, EmailVerificationConfig } from "@lib/appConfig";
|
|
13
|
+
import type { AuthRateLimitConfig } from "../app";
|
|
14
|
+
|
|
15
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
16
|
+
const TokenResponse = z.object({ token: z.string(), emailVerified: z.boolean().optional() });
|
|
17
|
+
const ErrorResponse = z.object({ error: z.string() });
|
|
18
|
+
|
|
19
|
+
const cookieOptions = {
|
|
20
|
+
httpOnly: true,
|
|
21
|
+
secure: isProd,
|
|
22
|
+
sameSite: "Lax" as const,
|
|
23
|
+
path: "/",
|
|
24
|
+
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface AuthRouterOptions {
|
|
28
|
+
primaryField: PrimaryField;
|
|
29
|
+
emailVerification?: EmailVerificationConfig;
|
|
30
|
+
rateLimit?: AuthRateLimitConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const createAuthRouter = ({ primaryField, emailVerification, rateLimit }: AuthRouterOptions) => {
|
|
34
|
+
const router = createRouter();
|
|
35
|
+
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
36
|
+
const LoginSchema = makeLoginSchema(primaryField);
|
|
37
|
+
const fieldLabel = primaryField.charAt(0).toUpperCase() + primaryField.slice(1);
|
|
38
|
+
const alreadyRegisteredMsg = `${fieldLabel} already registered`;
|
|
39
|
+
|
|
40
|
+
// Resolve limits with defaults
|
|
41
|
+
const loginOpts = { windowMs: rateLimit?.login?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.login?.max ?? 10 };
|
|
42
|
+
const registerOpts = { windowMs: rateLimit?.register?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.register?.max ?? 5 };
|
|
43
|
+
const verifyOpts = { windowMs: rateLimit?.verifyEmail?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.verifyEmail?.max ?? 10 };
|
|
44
|
+
const resendOpts = { windowMs: rateLimit?.resendVerification?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.resendVerification?.max ?? 3 };
|
|
45
|
+
|
|
46
|
+
router.openapi(
|
|
47
|
+
createRoute({
|
|
48
|
+
method: "post",
|
|
49
|
+
path: "/auth/register",
|
|
50
|
+
tags: ["Core"],
|
|
51
|
+
request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
|
|
52
|
+
responses: {
|
|
53
|
+
201: { content: { "application/json": { schema: TokenResponse } }, description: "Registered" },
|
|
54
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
55
|
+
409: { content: { "application/json": { schema: ErrorResponse } }, description: alreadyRegisteredMsg },
|
|
56
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
async (c) => {
|
|
60
|
+
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
61
|
+
if (await trackAttempt(`register:${ip}`, registerOpts)) {
|
|
62
|
+
return c.json({ error: "Too many registration attempts. Try again later." }, 429);
|
|
63
|
+
}
|
|
64
|
+
const body = c.req.valid("json") as Record<string, string>;
|
|
65
|
+
const identifier = body[primaryField];
|
|
66
|
+
const token = await AuthService.register(identifier, body.password);
|
|
67
|
+
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
68
|
+
return c.json({ token }, 201);
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
router.openapi(
|
|
73
|
+
createRoute({
|
|
74
|
+
method: "post",
|
|
75
|
+
path: "/auth/login",
|
|
76
|
+
tags: ["Core"],
|
|
77
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } } } },
|
|
78
|
+
responses: {
|
|
79
|
+
200: { content: { "application/json": { schema: TokenResponse } }, description: "Logged in" },
|
|
80
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials" },
|
|
81
|
+
403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified" },
|
|
82
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
async (c) => {
|
|
86
|
+
const body = c.req.valid("json") as Record<string, string>;
|
|
87
|
+
const identifier = body[primaryField];
|
|
88
|
+
const limitKey = `login:${identifier}`;
|
|
89
|
+
if (await isLimited(limitKey, loginOpts)) {
|
|
90
|
+
return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const result = await AuthService.login(identifier, body.password);
|
|
94
|
+
await bustAuthLimit(limitKey); // success — clear failure count
|
|
95
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
|
|
96
|
+
return c.json(result, 200);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
await trackAttempt(limitKey, loginOpts); // failure — count it
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
router.use("/auth/me", userAuth);
|
|
105
|
+
|
|
106
|
+
router.openapi(
|
|
107
|
+
createRoute({
|
|
108
|
+
method: "get",
|
|
109
|
+
path: "/auth/me",
|
|
110
|
+
tags: ["Core"],
|
|
111
|
+
responses: {
|
|
112
|
+
200: {
|
|
113
|
+
content: {
|
|
114
|
+
"application/json": {
|
|
115
|
+
schema: z.object({
|
|
116
|
+
userId: z.string(),
|
|
117
|
+
email: z.string().optional(),
|
|
118
|
+
emailVerified: z.boolean().optional(),
|
|
119
|
+
googleLinked: z.boolean().optional(),
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
description: "Current user",
|
|
124
|
+
},
|
|
125
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
|
|
126
|
+
},
|
|
127
|
+
}),
|
|
128
|
+
async (c) => {
|
|
129
|
+
const authUserId = c.get("authUserId")!;
|
|
130
|
+
const adapter = getAuthAdapter();
|
|
131
|
+
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
132
|
+
const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
133
|
+
return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
|
|
134
|
+
}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
router.use("/auth/set-password", userAuth);
|
|
138
|
+
|
|
139
|
+
router.openapi(
|
|
140
|
+
createRoute({
|
|
141
|
+
method: "post",
|
|
142
|
+
path: "/auth/set-password",
|
|
143
|
+
tags: ["Core"],
|
|
144
|
+
request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
|
|
145
|
+
responses: {
|
|
146
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password set" },
|
|
147
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
|
|
148
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
async (c) => {
|
|
152
|
+
const adapter = getAuthAdapter();
|
|
153
|
+
if (!adapter.setPassword) {
|
|
154
|
+
return c.json({ error: "Auth adapter does not support setPassword" }, 501);
|
|
155
|
+
}
|
|
156
|
+
const { password } = c.req.valid("json");
|
|
157
|
+
const authUserId = c.get("authUserId")!;
|
|
158
|
+
const passwordHash = await Bun.password.hash(password);
|
|
159
|
+
await adapter.setPassword(authUserId, passwordHash);
|
|
160
|
+
return c.json({ message: "Password updated" }, 200);
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
router.openapi(
|
|
165
|
+
createRoute({
|
|
166
|
+
method: "post",
|
|
167
|
+
path: "/auth/logout",
|
|
168
|
+
tags: ["Core"],
|
|
169
|
+
responses: {
|
|
170
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
async (c) => {
|
|
174
|
+
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
175
|
+
await AuthService.logout(token);
|
|
176
|
+
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
177
|
+
return c.json({ message: "Logged out" }, 200);
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
|
|
182
|
+
if (emailVerification && primaryField === "email") {
|
|
183
|
+
router.openapi(
|
|
184
|
+
createRoute({
|
|
185
|
+
method: "post",
|
|
186
|
+
path: "/auth/verify-email",
|
|
187
|
+
tags: ["Core"],
|
|
188
|
+
request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
|
|
189
|
+
responses: {
|
|
190
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
|
|
191
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired token" },
|
|
192
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
async (c) => {
|
|
196
|
+
const ip = c.req.header("x-forwarded-for") ?? "unknown";
|
|
197
|
+
if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
|
|
198
|
+
return c.json({ error: "Too many verification attempts. Try again later." }, 429);
|
|
199
|
+
}
|
|
200
|
+
const { token } = c.req.valid("json");
|
|
201
|
+
const adapter = getAuthAdapter();
|
|
202
|
+
const entry = await getVerificationToken(token);
|
|
203
|
+
if (!entry) return c.json({ error: "Invalid or expired verification token" }, 400);
|
|
204
|
+
if (adapter.setEmailVerified) await adapter.setEmailVerified(entry.userId, true);
|
|
205
|
+
await deleteVerificationToken(token);
|
|
206
|
+
return c.json({ message: "Email verified" }, 200);
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
router.use("/auth/resend-verification", userAuth);
|
|
211
|
+
|
|
212
|
+
router.openapi(
|
|
213
|
+
createRoute({
|
|
214
|
+
method: "post",
|
|
215
|
+
path: "/auth/resend-verification",
|
|
216
|
+
tags: ["Core"],
|
|
217
|
+
responses: {
|
|
218
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
|
|
219
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Already verified" },
|
|
220
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
|
|
221
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
async (c) => {
|
|
225
|
+
const adapter = getAuthAdapter();
|
|
226
|
+
if (!adapter.getEmailVerified || !adapter.getUser) {
|
|
227
|
+
return c.json({ error: "Auth adapter does not support email verification" }, 501);
|
|
228
|
+
}
|
|
229
|
+
const authUserId = c.get("authUserId")!;
|
|
230
|
+
if (await trackAttempt(`resend:${authUserId}`, resendOpts)) {
|
|
231
|
+
return c.json({ error: "Too many resend attempts. Try again later." }, 429);
|
|
232
|
+
}
|
|
233
|
+
const alreadyVerified = await adapter.getEmailVerified(authUserId);
|
|
234
|
+
if (alreadyVerified) return c.json({ error: "Email already verified" }, 400);
|
|
235
|
+
const user = await adapter.getUser(authUserId);
|
|
236
|
+
if (!user?.email) return c.json({ error: "No email address on file" }, 400);
|
|
237
|
+
const verificationToken = await createVerificationToken(authUserId, user.email);
|
|
238
|
+
await emailVerification.onSend(user.email, verificationToken);
|
|
239
|
+
return c.json({ message: "Verification email sent" }, 200);
|
|
240
|
+
}
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return router;
|
|
245
|
+
};
|