@lastshotlabs/bunshot 0.0.13 → 0.0.16
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 +2510 -1747
- package/dist/adapters/memoryAuth.d.ts +4 -0
- package/dist/adapters/memoryAuth.js +131 -2
- package/dist/adapters/mongoAuth.js +56 -0
- package/dist/adapters/sqliteAuth.d.ts +6 -0
- package/dist/adapters/sqliteAuth.js +137 -2
- package/dist/app.d.ts +77 -2
- package/dist/app.js +29 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +14 -5
- package/dist/index.js +9 -3
- package/dist/lib/appConfig.d.ts +46 -0
- package/dist/lib/appConfig.js +20 -0
- package/dist/lib/authAdapter.d.ts +30 -0
- package/dist/lib/constants.d.ts +2 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +2 -2
- package/dist/lib/mfaChallenge.d.ts +20 -0
- package/dist/lib/mfaChallenge.js +184 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +163 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/cacheResponse.js +4 -1
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +5 -2
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +8 -0
- package/dist/models/AuthUser.js +8 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +153 -22
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +1 -0
- package/dist/routes/mfa.js +409 -0
- package/dist/routes/oauth.js +107 -16
- package/dist/server.js +9 -0
- package/dist/services/auth.d.ts +17 -5
- package/dist/services/auth.js +95 -17
- package/dist/services/mfa.d.ts +37 -0
- package/dist/services/mfa.js +276 -0
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +456 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +135 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +99 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +83 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +62 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +119 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +43 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +115 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +100 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +19 -10
|
@@ -2,9 +2,11 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
2
2
|
/**
|
|
3
3
|
* Middleware factory that enforces role-based access.
|
|
4
4
|
* Requires `identify` to have run first (authUserId must be set).
|
|
5
|
-
* Roles are fetched lazily on the first role-checked route and cached on the context.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
6
|
+
* When tenant context exists (`tenantId` set on context), checks tenant-scoped roles.
|
|
7
|
+
* Falls back to app-wide roles when no tenant context is present.
|
|
8
|
+
*
|
|
9
|
+
* The adapter must implement `getRoles` (and `getTenantRoles` for tenant-scoped checks).
|
|
8
10
|
*
|
|
9
11
|
* @example
|
|
10
12
|
* // Allow any authenticated user with the "admin" role
|
|
@@ -13,15 +15,25 @@ import { getAuthAdapter } from "../lib/authAdapter";
|
|
|
13
15
|
* // Allow users with either "admin" or "moderator"
|
|
14
16
|
* app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
|
|
15
17
|
*/
|
|
16
|
-
export const requireRole = (...roles) => async (c, next) => {
|
|
18
|
+
export const requireRole = Object.assign((...roles) => async (c, next) => {
|
|
17
19
|
const userId = c.get("authUserId");
|
|
18
20
|
if (!userId) {
|
|
19
21
|
return c.json({ error: "Unauthorized" }, 401);
|
|
20
22
|
}
|
|
21
|
-
|
|
23
|
+
const adapter = getAuthAdapter();
|
|
24
|
+
const tenantId = c.get("tenantId");
|
|
25
|
+
// When tenant context exists and adapter supports tenant roles, check tenant-scoped roles
|
|
26
|
+
if (tenantId && adapter.getTenantRoles) {
|
|
27
|
+
const tenantRoles = await adapter.getTenantRoles(userId, tenantId);
|
|
28
|
+
const hasRole = roles.some((role) => tenantRoles.includes(role));
|
|
29
|
+
if (!hasRole) {
|
|
30
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
31
|
+
}
|
|
32
|
+
return next();
|
|
33
|
+
}
|
|
34
|
+
// Fall back to app-wide roles
|
|
22
35
|
let userRoles = c.get("roles");
|
|
23
36
|
if (userRoles === null) {
|
|
24
|
-
const adapter = getAuthAdapter();
|
|
25
37
|
if (!adapter.getRoles) {
|
|
26
38
|
throw new Error("requireRole used but auth adapter does not implement getRoles");
|
|
27
39
|
}
|
|
@@ -33,4 +45,32 @@ export const requireRole = (...roles) => async (c, next) => {
|
|
|
33
45
|
return c.json({ error: "Forbidden" }, 403);
|
|
34
46
|
}
|
|
35
47
|
await next();
|
|
36
|
-
}
|
|
48
|
+
}, {
|
|
49
|
+
/**
|
|
50
|
+
* Always checks app-wide roles regardless of tenant context.
|
|
51
|
+
* Use for super-admin gates that should ignore tenant scoping.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* app.get("/super-admin", userAuth, requireRole.global("superadmin"), handler)
|
|
55
|
+
*/
|
|
56
|
+
global: (...roles) => async (c, next) => {
|
|
57
|
+
const userId = c.get("authUserId");
|
|
58
|
+
if (!userId) {
|
|
59
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
60
|
+
}
|
|
61
|
+
let userRoles = c.get("roles");
|
|
62
|
+
if (userRoles === null) {
|
|
63
|
+
const adapter = getAuthAdapter();
|
|
64
|
+
if (!adapter.getRoles) {
|
|
65
|
+
throw new Error("requireRole.global used but auth adapter does not implement getRoles");
|
|
66
|
+
}
|
|
67
|
+
userRoles = await adapter.getRoles(userId);
|
|
68
|
+
c.set("roles", userRoles);
|
|
69
|
+
}
|
|
70
|
+
const hasRole = roles.some((role) => userRoles.includes(role));
|
|
71
|
+
if (!hasRole) {
|
|
72
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
73
|
+
}
|
|
74
|
+
await next();
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
import type { AppEnv } from "../lib/context";
|
|
3
|
+
import type { TenancyConfig } from "../app";
|
|
4
|
+
export declare const invalidateTenantCache: (tenantId: string) => void;
|
|
5
|
+
export declare const createTenantMiddleware: (config: TenancyConfig) => MiddlewareHandler<AppEnv>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
class LruCache {
|
|
2
|
+
_map = new Map();
|
|
3
|
+
_maxSize;
|
|
4
|
+
_ttlMs;
|
|
5
|
+
constructor(maxSize, ttlMs) {
|
|
6
|
+
this._maxSize = maxSize;
|
|
7
|
+
this._ttlMs = ttlMs;
|
|
8
|
+
}
|
|
9
|
+
get(key) {
|
|
10
|
+
const entry = this._map.get(key);
|
|
11
|
+
if (!entry)
|
|
12
|
+
return undefined; // cache miss
|
|
13
|
+
if (entry.expiresAt <= Date.now()) {
|
|
14
|
+
this._map.delete(key);
|
|
15
|
+
return undefined; // expired
|
|
16
|
+
}
|
|
17
|
+
// Move to end (most recently used)
|
|
18
|
+
this._map.delete(key);
|
|
19
|
+
this._map.set(key, entry);
|
|
20
|
+
return entry.value;
|
|
21
|
+
}
|
|
22
|
+
set(key, value) {
|
|
23
|
+
// Remove first if exists (for re-insertion at end)
|
|
24
|
+
this._map.delete(key);
|
|
25
|
+
// Evict oldest if at capacity
|
|
26
|
+
if (this._map.size >= this._maxSize) {
|
|
27
|
+
const oldest = this._map.keys().next().value;
|
|
28
|
+
if (oldest !== undefined)
|
|
29
|
+
this._map.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs });
|
|
32
|
+
}
|
|
33
|
+
delete(key) {
|
|
34
|
+
this._map.delete(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Exported cache invalidation (used by tenant provisioning helpers)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
let _cache = null;
|
|
41
|
+
export const invalidateTenantCache = (tenantId) => {
|
|
42
|
+
_cache?.delete(tenantId);
|
|
43
|
+
};
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Tenant resolution middleware
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
const DEFAULT_EXEMPT = ["/health", "/docs", "/openapi.json", "/auth/"];
|
|
48
|
+
function extractTenantId(c, config) {
|
|
49
|
+
if (config.resolution === "header") {
|
|
50
|
+
const headerName = config.headerName ?? "x-tenant-id";
|
|
51
|
+
return c.req.header(headerName) ?? null;
|
|
52
|
+
}
|
|
53
|
+
if (config.resolution === "subdomain") {
|
|
54
|
+
const host = c.req.header("host") ?? "";
|
|
55
|
+
// Extract first subdomain: "acme.myapp.com" → "acme"
|
|
56
|
+
const parts = host.split(".");
|
|
57
|
+
if (parts.length < 3)
|
|
58
|
+
return null; // no subdomain
|
|
59
|
+
return parts[0] || null;
|
|
60
|
+
}
|
|
61
|
+
if (config.resolution === "path") {
|
|
62
|
+
const segmentIndex = config.pathSegment ?? 0;
|
|
63
|
+
// Path: "/acme/api/users" → segments after split: ["", "acme", "api", "users"]
|
|
64
|
+
const segments = c.req.path.split("/").filter(Boolean);
|
|
65
|
+
return segments[segmentIndex] ?? null;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export const createTenantMiddleware = (config) => {
|
|
70
|
+
const exemptPaths = [...DEFAULT_EXEMPT, ...(config.exemptPaths ?? [])];
|
|
71
|
+
const rejectionStatus = config.rejectionStatus ?? 403;
|
|
72
|
+
const cacheTtlMs = config.cacheTtlMs ?? 60_000;
|
|
73
|
+
const cacheMaxSize = config.cacheMaxSize ?? 500;
|
|
74
|
+
// Initialize LRU cache if caching is enabled and onResolve is provided
|
|
75
|
+
if (config.onResolve && cacheTtlMs > 0) {
|
|
76
|
+
_cache = new LruCache(cacheMaxSize, cacheTtlMs);
|
|
77
|
+
}
|
|
78
|
+
return async (c, next) => {
|
|
79
|
+
const path = c.req.path;
|
|
80
|
+
// Check exempt paths using startsWith
|
|
81
|
+
for (const exempt of exemptPaths) {
|
|
82
|
+
if (path === exempt || path.startsWith(exempt)) {
|
|
83
|
+
c.set("tenantId", null);
|
|
84
|
+
c.set("tenantConfig", null);
|
|
85
|
+
return next();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const tenantId = extractTenantId(c, config);
|
|
89
|
+
if (!tenantId) {
|
|
90
|
+
return c.json({ error: "Tenant ID required" }, 400);
|
|
91
|
+
}
|
|
92
|
+
// Validate via onResolve (with caching)
|
|
93
|
+
if (config.onResolve) {
|
|
94
|
+
let tenantConfig;
|
|
95
|
+
if (_cache) {
|
|
96
|
+
tenantConfig = _cache.get(tenantId);
|
|
97
|
+
}
|
|
98
|
+
// undefined = cache miss, null = onResolve returned null (rejected)
|
|
99
|
+
if (tenantConfig === undefined) {
|
|
100
|
+
tenantConfig = await config.onResolve(tenantId);
|
|
101
|
+
_cache?.set(tenantId, tenantConfig);
|
|
102
|
+
}
|
|
103
|
+
if (tenantConfig === null) {
|
|
104
|
+
return c.json({ error: "Access denied" }, rejectionStatus);
|
|
105
|
+
}
|
|
106
|
+
c.set("tenantId", tenantId);
|
|
107
|
+
c.set("tenantConfig", tenantConfig);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// No onResolve — trust the tenant ID
|
|
111
|
+
c.set("tenantId", tenantId);
|
|
112
|
+
c.set("tenantConfig", null);
|
|
113
|
+
}
|
|
114
|
+
return next();
|
|
115
|
+
};
|
|
116
|
+
};
|
|
@@ -8,6 +8,14 @@ interface IAuthUser {
|
|
|
8
8
|
roles: string[];
|
|
9
9
|
/** Whether the user's email address has been verified. */
|
|
10
10
|
emailVerified: boolean;
|
|
11
|
+
/** TOTP secret for MFA. Null when MFA is not set up. */
|
|
12
|
+
mfaSecret?: string | null;
|
|
13
|
+
/** Whether MFA is enabled (secret stored + confirmed via TOTP code). */
|
|
14
|
+
mfaEnabled?: boolean;
|
|
15
|
+
/** SHA-256 hashed recovery codes for MFA. */
|
|
16
|
+
recoveryCodes?: string[];
|
|
17
|
+
/** MFA methods enabled for this user (e.g., ["totp"], ["emailOtp"], ["totp", "emailOtp"]). */
|
|
18
|
+
mfaMethods?: string[];
|
|
11
19
|
}
|
|
12
20
|
type AuthUserDocument = IAuthUser & Document;
|
|
13
21
|
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
|
@@ -14,6 +14,14 @@ function getAuthUser() {
|
|
|
14
14
|
roles: [{ type: String }],
|
|
15
15
|
/** Whether the user's email address has been verified. */
|
|
16
16
|
emailVerified: { type: Boolean, default: false },
|
|
17
|
+
/** TOTP secret for MFA. */
|
|
18
|
+
mfaSecret: { type: String, default: null },
|
|
19
|
+
/** Whether MFA is enabled. */
|
|
20
|
+
mfaEnabled: { type: Boolean, default: false },
|
|
21
|
+
/** SHA-256 hashed recovery codes for MFA. */
|
|
22
|
+
recoveryCodes: [{ type: String }],
|
|
23
|
+
/** MFA methods enabled for this user. */
|
|
24
|
+
mfaMethods: [{ type: String }],
|
|
17
25
|
}, { timestamps: true });
|
|
18
26
|
schema.index({ providerIds: 1 });
|
|
19
27
|
_AuthUser = authConnection.model("AuthUser", schema);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Document, Model } from "mongoose";
|
|
2
|
+
interface ITenantRole {
|
|
3
|
+
userId: string;
|
|
4
|
+
tenantId: string;
|
|
5
|
+
roles: string[];
|
|
6
|
+
}
|
|
7
|
+
type TenantRoleDocument = ITenantRole & Document;
|
|
8
|
+
export declare const TenantRole: Model<TenantRoleDocument, {}, {}, {}, Document<unknown, {}, TenantRoleDocument, {}, import("mongoose").DefaultSchemaOptions> & ITenantRole & Document<import("mongoose").Types.ObjectId, any, any, Record<string, any>, {}> & Required<{
|
|
9
|
+
_id: import("mongoose").Types.ObjectId;
|
|
10
|
+
}> & {
|
|
11
|
+
__v: number;
|
|
12
|
+
} & {
|
|
13
|
+
id: string;
|
|
14
|
+
}, any, TenantRoleDocument>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { authConnection, mongoose } from "../lib/mongo";
|
|
2
|
+
let _TenantRole = null;
|
|
3
|
+
function getTenantRole() {
|
|
4
|
+
if (!_TenantRole) {
|
|
5
|
+
const { Schema } = mongoose;
|
|
6
|
+
const schema = new Schema({
|
|
7
|
+
userId: { type: String, required: true },
|
|
8
|
+
tenantId: { type: String, required: true },
|
|
9
|
+
roles: [{ type: String }],
|
|
10
|
+
}, { timestamps: true });
|
|
11
|
+
schema.index({ userId: 1, tenantId: 1 }, { unique: true });
|
|
12
|
+
schema.index({ tenantId: 1 });
|
|
13
|
+
_TenantRole = authConnection.model("TenantRole", schema);
|
|
14
|
+
}
|
|
15
|
+
return _TenantRole;
|
|
16
|
+
}
|
|
17
|
+
export const TenantRole = new Proxy({}, {
|
|
18
|
+
get(_, prop) {
|
|
19
|
+
const model = getTenantRole();
|
|
20
|
+
const val = model[prop];
|
|
21
|
+
return typeof val === "function" ? val.bind(model) : val;
|
|
22
|
+
},
|
|
23
|
+
});
|
package/dist/routes/auth.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig } from "../lib/appConfig";
|
|
2
|
-
import type { AuthRateLimitConfig } from "../app";
|
|
1
|
+
import type { PrimaryField, EmailVerificationConfig, PasswordResetConfig, RefreshTokenConfig } from "../lib/appConfig";
|
|
2
|
+
import type { AuthRateLimitConfig, AccountDeletionConfig } from "../app";
|
|
3
3
|
export interface AuthRouterOptions {
|
|
4
4
|
primaryField: PrimaryField;
|
|
5
5
|
emailVerification?: EmailVerificationConfig;
|
|
6
6
|
passwordReset?: PasswordResetConfig;
|
|
7
7
|
rateLimit?: AuthRateLimitConfig;
|
|
8
|
+
accountDeletion?: AccountDeletionConfig;
|
|
9
|
+
refreshTokens?: RefreshTokenConfig;
|
|
8
10
|
}
|
|
9
|
-
export declare const createAuthRouter: ({ primaryField, emailVerification, passwordReset, rateLimit }: AuthRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|
|
11
|
+
export declare const createAuthRouter: ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }: AuthRouterOptions) => import("@hono/zod-openapi").OpenAPIHono<import("../lib/context").AppEnv, {}, "/">;
|
package/dist/routes/auth.js
CHANGED
|
@@ -3,33 +3,38 @@ import { z } from "zod";
|
|
|
3
3
|
import { setCookie, getCookie, deleteCookie } from "hono/cookie";
|
|
4
4
|
import * as AuthService from "../services/auth";
|
|
5
5
|
import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
|
|
6
|
-
import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
|
|
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";
|
|
9
9
|
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 {
|
|
13
|
+
import { getRefreshTokenExpiry, getAccessTokenExpiry } from "../lib/appConfig";
|
|
14
|
+
import { getUserSessions, deleteSession, deleteUserSessions } from "../lib/session";
|
|
14
15
|
const isProd = process.env.NODE_ENV === "production";
|
|
15
16
|
const TokenResponse = z.object({
|
|
16
|
-
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie."),
|
|
17
|
+
token: z.string().describe("JWT session token. Also set as an HttpOnly session cookie. Empty string when mfaRequired is true."),
|
|
17
18
|
userId: z.string().describe("Unique user ID."),
|
|
18
19
|
email: z.string().optional().describe("User's email address (present when primaryField is 'email')."),
|
|
19
20
|
emailVerified: z.boolean().optional().describe("Whether the email address has been verified (present when emailVerification is configured)."),
|
|
20
21
|
googleLinked: z.boolean().optional().describe("Whether a Google OAuth account is linked to this user."),
|
|
22
|
+
refreshToken: z.string().optional().describe("Refresh token (present when refreshTokens is configured). Also set as an HttpOnly cookie."),
|
|
23
|
+
mfaRequired: z.boolean().optional().describe("When true, complete MFA via POST /auth/mfa/verify before accessing the API."),
|
|
24
|
+
mfaToken: z.string().optional().describe("MFA challenge token. Pass to POST /auth/mfa/verify with a TOTP or recovery code."),
|
|
25
|
+
mfaMethods: z.array(z.string()).optional().describe("Available MFA methods when mfaRequired is true (e.g., 'totp', 'emailOtp')."),
|
|
21
26
|
}).openapi("TokenResponse");
|
|
22
27
|
const ErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("ErrorResponse");
|
|
23
28
|
const tags = ["Auth"];
|
|
24
|
-
const cookieOptions = {
|
|
29
|
+
const cookieOptions = (maxAge) => ({
|
|
25
30
|
httpOnly: true,
|
|
26
31
|
secure: isProd,
|
|
27
32
|
sameSite: "Lax",
|
|
28
33
|
path: "/",
|
|
29
|
-
maxAge: 60 * 60 * 24 * 7, // 7 days
|
|
30
|
-
};
|
|
34
|
+
maxAge: maxAge ?? 60 * 60 * 24 * 7, // 7 days
|
|
35
|
+
});
|
|
31
36
|
const clientIp = (xff, xri) => (xff ? xff.split(",")[0]?.trim() : undefined) ?? xri ?? undefined;
|
|
32
|
-
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit }) => {
|
|
37
|
+
export const createAuthRouter = ({ primaryField, emailVerification, passwordReset, rateLimit, accountDeletion, refreshTokens }) => {
|
|
33
38
|
const router = createRouter();
|
|
34
39
|
const RegisterSchema = makeRegisterSchema(primaryField);
|
|
35
40
|
const LoginSchema = makeLoginSchema(primaryField);
|
|
@@ -67,7 +72,10 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
67
72
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
68
73
|
};
|
|
69
74
|
const result = await AuthService.register(identifier, body.password, metadata);
|
|
70
|
-
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
|
|
75
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
|
|
76
|
+
if (result.refreshToken) {
|
|
77
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
78
|
+
}
|
|
71
79
|
return c.json(result, 201);
|
|
72
80
|
});
|
|
73
81
|
router.openapi(createRoute({
|
|
@@ -97,7 +105,12 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
97
105
|
try {
|
|
98
106
|
const result = await AuthService.login(identifier, body.password, metadata);
|
|
99
107
|
await bustAuthLimit(limitKey); // success — clear failure count
|
|
100
|
-
|
|
108
|
+
if (!result.mfaRequired) {
|
|
109
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(refreshTokens ? getAccessTokenExpiry() : undefined));
|
|
110
|
+
if (result.refreshToken) {
|
|
111
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
101
114
|
return c.json(result, 200);
|
|
102
115
|
}
|
|
103
116
|
catch (err) {
|
|
@@ -135,6 +148,74 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
135
148
|
const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
|
|
136
149
|
return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
|
|
137
150
|
});
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Account deletion
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
const deleteAccountOpts = { windowMs: rateLimit?.deleteAccount?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.deleteAccount?.max ?? 3 };
|
|
155
|
+
router.openapi(withSecurity(createRoute({
|
|
156
|
+
method: "delete",
|
|
157
|
+
path: "/auth/me",
|
|
158
|
+
summary: "Delete account",
|
|
159
|
+
description: "Permanently deletes the authenticated user's account. Requires password confirmation for credential accounts. MFA is not required — the password serves as the identity check. Revokes all active sessions.",
|
|
160
|
+
tags,
|
|
161
|
+
request: {
|
|
162
|
+
body: {
|
|
163
|
+
content: {
|
|
164
|
+
"application/json": {
|
|
165
|
+
schema: z.object({
|
|
166
|
+
password: z.string().optional().describe("Current password. Required for credential accounts, optional for OAuth-only accounts."),
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
description: "Password confirmation.",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
responses: {
|
|
174
|
+
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deleted." },
|
|
175
|
+
202: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Account deletion has been scheduled." },
|
|
176
|
+
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Password is required for credential accounts." },
|
|
177
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid password or no valid session." },
|
|
178
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many deletion attempts. Try again later." },
|
|
179
|
+
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support deleteUser." },
|
|
180
|
+
},
|
|
181
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
182
|
+
const authUserId = c.get("authUserId");
|
|
183
|
+
if (await trackAttempt(`deleteaccount:${authUserId}`, deleteAccountOpts)) {
|
|
184
|
+
return c.json({ error: "Too many deletion attempts. Try again later." }, 429);
|
|
185
|
+
}
|
|
186
|
+
const adapter = getAuthAdapter();
|
|
187
|
+
if (!adapter.deleteUser) {
|
|
188
|
+
return c.json({ error: "Auth adapter does not support deleteUser" }, 501);
|
|
189
|
+
}
|
|
190
|
+
const { password } = c.req.valid("json");
|
|
191
|
+
// Verify password for credential accounts
|
|
192
|
+
if (password) {
|
|
193
|
+
const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
|
|
194
|
+
const email = user?.email;
|
|
195
|
+
if (email) {
|
|
196
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
197
|
+
const found = await findFn(email);
|
|
198
|
+
if (found && !(await Bun.password.verify(password, found.passwordHash))) {
|
|
199
|
+
return c.json({ error: "Invalid password" }, 401);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (adapter.hasPassword && await adapter.hasPassword(authUserId)) {
|
|
204
|
+
return c.json({ error: "Password is required to delete a credential account" }, 400);
|
|
205
|
+
}
|
|
206
|
+
// Call onBeforeDelete hook
|
|
207
|
+
if (accountDeletion?.onBeforeDelete) {
|
|
208
|
+
await accountDeletion.onBeforeDelete(authUserId);
|
|
209
|
+
}
|
|
210
|
+
// Synchronous deletion (default)
|
|
211
|
+
await deleteUserSessions(authUserId);
|
|
212
|
+
await adapter.deleteUser(authUserId);
|
|
213
|
+
if (accountDeletion?.onAfterDelete) {
|
|
214
|
+
await accountDeletion.onAfterDelete(authUserId);
|
|
215
|
+
}
|
|
216
|
+
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
217
|
+
return c.json({ message: "Account deleted" }, 200);
|
|
218
|
+
});
|
|
138
219
|
router.use("/auth/set-password", userAuth);
|
|
139
220
|
router.openapi(withSecurity(createRoute({
|
|
140
221
|
method: "post",
|
|
@@ -173,6 +254,7 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
173
254
|
const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
|
|
174
255
|
await AuthService.logout(token);
|
|
175
256
|
deleteCookie(c, COOKIE_TOKEN, { path: "/" });
|
|
257
|
+
deleteCookie(c, COOKIE_REFRESH_TOKEN, { path: "/" });
|
|
176
258
|
return c.json({ message: "Logged out" }, 200);
|
|
177
259
|
});
|
|
178
260
|
// Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
|
|
@@ -204,37 +286,43 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
204
286
|
await deleteVerificationToken(token);
|
|
205
287
|
return c.json({ message: "Email verified" }, 200);
|
|
206
288
|
});
|
|
207
|
-
router.
|
|
208
|
-
router.openapi(withSecurity(createRoute({
|
|
289
|
+
router.openapi(createRoute({
|
|
209
290
|
method: "post",
|
|
210
291
|
path: "/auth/resend-verification",
|
|
211
292
|
summary: "Resend verification email",
|
|
212
|
-
description: "
|
|
293
|
+
description: "Authenticates with credentials and sends a new verification email. Returns 400 if already verified. Rate-limited per identifier. Does not require a session.",
|
|
213
294
|
tags,
|
|
295
|
+
request: { body: { content: { "application/json": { schema: LoginSchema } }, description: "Login credentials to identify the account." } },
|
|
214
296
|
responses: {
|
|
215
297
|
200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent." },
|
|
216
298
|
400: { content: { "application/json": { schema: ErrorResponse } }, description: "Email is already verified, or no email address on file." },
|
|
217
|
-
401: { content: { "application/json": { schema: ErrorResponse } }, description: "
|
|
218
|
-
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this
|
|
299
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials." },
|
|
300
|
+
429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many resend attempts for this identifier. Try again later." },
|
|
219
301
|
501: { content: { "application/json": { schema: ErrorResponse } }, description: "The configured auth adapter does not support email verification." },
|
|
220
302
|
},
|
|
221
|
-
}),
|
|
303
|
+
}), async (c) => {
|
|
222
304
|
const adapter = getAuthAdapter();
|
|
223
305
|
if (!adapter.getEmailVerified || !adapter.getUser) {
|
|
224
306
|
return c.json({ error: "Auth adapter does not support email verification" }, 501);
|
|
225
307
|
}
|
|
226
|
-
const
|
|
227
|
-
|
|
308
|
+
const body = c.req.valid("json");
|
|
309
|
+
const identifier = body[primaryField];
|
|
310
|
+
if (await trackAttempt(`resend:${identifier}`, resendOpts)) {
|
|
228
311
|
return c.json({ error: "Too many resend attempts. Try again later." }, 429);
|
|
229
312
|
}
|
|
230
|
-
const
|
|
313
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
314
|
+
const user = await findFn(identifier);
|
|
315
|
+
if (!user || !(await Bun.password.verify(body.password, user.passwordHash))) {
|
|
316
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
317
|
+
}
|
|
318
|
+
const alreadyVerified = await adapter.getEmailVerified(user.id);
|
|
231
319
|
if (alreadyVerified)
|
|
232
320
|
return c.json({ error: "Email already verified" }, 400);
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
321
|
+
const fullUser = await adapter.getUser(user.id);
|
|
322
|
+
if (!fullUser?.email)
|
|
235
323
|
return c.json({ error: "No email address on file" }, 400);
|
|
236
|
-
const verificationToken = await createVerificationToken(
|
|
237
|
-
await emailVerification.onSend(
|
|
324
|
+
const verificationToken = await createVerificationToken(user.id, fullUser.email);
|
|
325
|
+
await emailVerification.onSend(fullUser.email, verificationToken);
|
|
238
326
|
return c.json({ message: "Verification email sent" }, 200);
|
|
239
327
|
});
|
|
240
328
|
}
|
|
@@ -327,6 +415,49 @@ export const createAuthRouter = ({ primaryField, emailVerification, passwordRese
|
|
|
327
415
|
});
|
|
328
416
|
}
|
|
329
417
|
// ---------------------------------------------------------------------------
|
|
418
|
+
// Refresh token route — only mounted when refreshTokens is configured
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
if (refreshTokens) {
|
|
421
|
+
const RefreshResponse = z.object({
|
|
422
|
+
token: z.string().describe("New short-lived JWT access token."),
|
|
423
|
+
refreshToken: z.string().describe("New refresh token (rotation). The previous token is valid for a short grace window."),
|
|
424
|
+
userId: z.string().describe("Unique user ID."),
|
|
425
|
+
}).openapi("RefreshResponse");
|
|
426
|
+
router.openapi(createRoute({
|
|
427
|
+
method: "post",
|
|
428
|
+
path: "/auth/refresh",
|
|
429
|
+
summary: "Refresh access token",
|
|
430
|
+
description: "Exchanges a valid refresh token for a new access token and rotated refresh token. The old refresh token remains valid for a short grace window to handle network drops. If a previously rotated token is reused after the grace window, the entire session is invalidated (token theft detection).",
|
|
431
|
+
tags,
|
|
432
|
+
request: {
|
|
433
|
+
body: {
|
|
434
|
+
content: {
|
|
435
|
+
"application/json": {
|
|
436
|
+
schema: z.object({
|
|
437
|
+
refreshToken: z.string().optional().describe("Refresh token. Can also be sent via the refresh_token cookie or x-refresh-token header."),
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
description: "Refresh token (optional if sent via cookie or header).",
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
responses: {
|
|
445
|
+
200: { content: { "application/json": { schema: RefreshResponse } }, description: "New access and refresh tokens." },
|
|
446
|
+
401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired refresh token, or session invalidated due to token theft detection." },
|
|
447
|
+
},
|
|
448
|
+
}), async (c) => {
|
|
449
|
+
const body = c.req.valid("json");
|
|
450
|
+
const rt = body.refreshToken ?? getCookie(c, COOKIE_REFRESH_TOKEN) ?? c.req.header(HEADER_REFRESH_TOKEN) ?? null;
|
|
451
|
+
if (!rt) {
|
|
452
|
+
return c.json({ error: "Refresh token is required" }, 401);
|
|
453
|
+
}
|
|
454
|
+
const result = await AuthService.refresh(rt);
|
|
455
|
+
setCookie(c, COOKIE_TOKEN, result.token, cookieOptions(getAccessTokenExpiry()));
|
|
456
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, result.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
457
|
+
return c.json(result, 200);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
330
461
|
// Session management
|
|
331
462
|
// ---------------------------------------------------------------------------
|
|
332
463
|
const SessionInfoSchema = z.object({
|