@wopr-network/platform-core 1.0.1 → 1.0.3
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/dist/auth/better-auth.d.ts +47 -0
- package/dist/auth/better-auth.js +77 -49
- package/dist/auth/better-auth.test.d.ts +1 -0
- package/dist/auth/better-auth.test.js +35 -0
- package/dist/auth/index.d.ts +12 -0
- package/dist/auth/index.js +7 -0
- package/package.json +1 -1
- package/src/auth/better-auth.test.ts +47 -0
- package/src/auth/better-auth.ts +138 -52
- package/src/auth/index.ts +32 -0
- package/vitest.config.ts +1 -0
|
@@ -11,13 +11,60 @@ import { betterAuth } from "better-auth";
|
|
|
11
11
|
import type { Pool } from "pg";
|
|
12
12
|
import type { PlatformDb } from "../db/index.js";
|
|
13
13
|
import { PgEmailVerifier } from "../email/verification.js";
|
|
14
|
+
import { type IUserCreator } from "./user-creator.js";
|
|
15
|
+
/** OAuth provider credentials. */
|
|
16
|
+
export interface OAuthProvider {
|
|
17
|
+
clientId: string;
|
|
18
|
+
clientSecret: string;
|
|
19
|
+
}
|
|
20
|
+
/** Rate limit rule for a specific auth endpoint. */
|
|
21
|
+
export interface AuthRateLimitRule {
|
|
22
|
+
window: number;
|
|
23
|
+
max: number;
|
|
24
|
+
}
|
|
14
25
|
/** Configuration for initializing Better Auth in platform-core. */
|
|
15
26
|
export interface BetterAuthConfig {
|
|
16
27
|
pool: Pool;
|
|
17
28
|
db: PlatformDb;
|
|
29
|
+
/** HMAC secret for session tokens. Falls back to BETTER_AUTH_SECRET env var. */
|
|
30
|
+
secret?: string;
|
|
31
|
+
/** Base URL for OAuth callbacks. Falls back to BETTER_AUTH_URL env var. */
|
|
32
|
+
baseURL?: string;
|
|
33
|
+
/** Route prefix. Default: "/api/auth" */
|
|
34
|
+
basePath?: string;
|
|
35
|
+
/** Email+password config. Default: enabled with 12-char min. */
|
|
36
|
+
emailAndPassword?: {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
minPasswordLength?: number;
|
|
39
|
+
};
|
|
40
|
+
/** OAuth providers. Default: reads GITHUB/DISCORD/GOOGLE env vars. */
|
|
41
|
+
socialProviders?: {
|
|
42
|
+
github?: OAuthProvider;
|
|
43
|
+
discord?: OAuthProvider;
|
|
44
|
+
google?: OAuthProvider;
|
|
45
|
+
};
|
|
46
|
+
/** Trusted providers for account linking. Default: ["github", "google"] */
|
|
47
|
+
trustedProviders?: string[];
|
|
48
|
+
/** Enable 2FA plugin. Default: true */
|
|
49
|
+
twoFactor?: boolean;
|
|
50
|
+
/** Cookie cache max age in seconds. Default: 300 (5 min) */
|
|
51
|
+
sessionCacheMaxAge?: number;
|
|
52
|
+
/** Cookie prefix. Default: "better-auth" */
|
|
53
|
+
cookiePrefix?: string;
|
|
54
|
+
/** Cookie domain (e.g., ".wopr.bot"). Falls back to COOKIE_DOMAIN env var. */
|
|
55
|
+
cookieDomain?: string;
|
|
56
|
+
/** Global rate limit window in seconds. Default: 60 */
|
|
57
|
+
rateLimitWindow?: number;
|
|
58
|
+
/** Global rate limit max requests. Default: 100 */
|
|
59
|
+
rateLimitMax?: number;
|
|
60
|
+
/** Per-endpoint rate limit overrides. Default: sign-in/sign-up/reset limits. */
|
|
61
|
+
rateLimitRules?: Record<string, AuthRateLimitRule>;
|
|
62
|
+
/** Trusted origins for CORS. Falls back to UI_ORIGIN env var. */
|
|
63
|
+
trustedOrigins?: string[];
|
|
18
64
|
/** Called after a new user signs up (e.g., create personal tenant). */
|
|
19
65
|
onUserCreated?: (userId: string, userName: string, email: string) => Promise<void>;
|
|
20
66
|
}
|
|
67
|
+
export declare function getUserCreator(): Promise<IUserCreator>;
|
|
21
68
|
/** The type of a better-auth instance. */
|
|
22
69
|
export type Auth = ReturnType<typeof betterAuth>;
|
|
23
70
|
/** Initialize Better Auth with the given config. Must be called before getAuth(). */
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -16,50 +16,83 @@ import { getEmailClient } from "../email/client.js";
|
|
|
16
16
|
import { passwordResetEmailTemplate, verifyEmailTemplate } from "../email/templates.js";
|
|
17
17
|
import { generateVerificationToken, initVerificationSchema, PgEmailVerifier } from "../email/verification.js";
|
|
18
18
|
import { createUserCreator } from "./user-creator.js";
|
|
19
|
-
const
|
|
20
|
-
|
|
19
|
+
const DEFAULT_RATE_LIMIT_RULES = {
|
|
20
|
+
"/sign-in/email": { window: 900, max: 5 },
|
|
21
|
+
"/sign-up/email": { window: 3600, max: 10 },
|
|
22
|
+
"/request-password-reset": { window: 3600, max: 3 },
|
|
23
|
+
};
|
|
21
24
|
let _config = null;
|
|
22
25
|
let _userCreator = null;
|
|
23
26
|
let _userCreatorPromise = null;
|
|
24
|
-
async function getUserCreator() {
|
|
27
|
+
export async function getUserCreator() {
|
|
25
28
|
if (_userCreator)
|
|
26
29
|
return _userCreator;
|
|
27
30
|
if (!_userCreatorPromise) {
|
|
28
31
|
if (!_config)
|
|
29
32
|
throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
30
|
-
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
33
|
+
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
34
|
+
.then((creator) => {
|
|
31
35
|
_userCreator = creator;
|
|
32
36
|
return creator;
|
|
37
|
+
})
|
|
38
|
+
.catch((err) => {
|
|
39
|
+
_userCreatorPromise = null;
|
|
40
|
+
throw err;
|
|
33
41
|
});
|
|
34
42
|
}
|
|
35
43
|
return _userCreatorPromise;
|
|
36
44
|
}
|
|
37
|
-
|
|
45
|
+
/** Resolve OAuth providers from config or env vars. */
|
|
46
|
+
function resolveSocialProviders(cfg) {
|
|
47
|
+
if (cfg.socialProviders)
|
|
48
|
+
return cfg.socialProviders;
|
|
49
|
+
return {
|
|
50
|
+
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
51
|
+
? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
|
|
52
|
+
: {}),
|
|
53
|
+
...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
|
|
54
|
+
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
|
|
55
|
+
: {}),
|
|
56
|
+
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
57
|
+
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function authOptions(cfg) {
|
|
62
|
+
const pool = cfg.pool;
|
|
63
|
+
const secret = cfg.secret || process.env.BETTER_AUTH_SECRET;
|
|
64
|
+
if (!secret) {
|
|
65
|
+
if (process.env.NODE_ENV === "production") {
|
|
66
|
+
throw new Error("BETTER_AUTH_SECRET is required in production");
|
|
67
|
+
}
|
|
68
|
+
logger.warn("BetterAuth secret not configured — sessions may be insecure");
|
|
69
|
+
}
|
|
70
|
+
const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
|
|
71
|
+
const basePath = cfg.basePath || "/api/auth";
|
|
72
|
+
const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
|
|
73
|
+
const trustedOrigins = cfg.trustedOrigins ||
|
|
74
|
+
(process.env.UI_ORIGIN || "http://localhost:3001")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((o) => o.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
// Default minPasswordLength: 12 — caller must explicitly override, not accidentally omit
|
|
79
|
+
const emailAndPassword = cfg.emailAndPassword
|
|
80
|
+
? { minPasswordLength: 12, ...cfg.emailAndPassword }
|
|
81
|
+
: { enabled: true, minPasswordLength: 12 };
|
|
38
82
|
return {
|
|
39
83
|
database: pool,
|
|
40
|
-
secret:
|
|
41
|
-
baseURL
|
|
42
|
-
basePath
|
|
43
|
-
socialProviders:
|
|
44
|
-
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
45
|
-
? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
|
|
46
|
-
: {}),
|
|
47
|
-
...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
|
|
48
|
-
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
|
|
49
|
-
: {}),
|
|
50
|
-
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
51
|
-
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
|
|
52
|
-
: {}),
|
|
53
|
-
},
|
|
84
|
+
secret: secret || "",
|
|
85
|
+
baseURL,
|
|
86
|
+
basePath,
|
|
87
|
+
socialProviders: resolveSocialProviders(cfg),
|
|
54
88
|
account: {
|
|
55
89
|
accountLinking: {
|
|
56
90
|
enabled: true,
|
|
57
|
-
trustedProviders: ["github", "google"],
|
|
91
|
+
trustedProviders: cfg.trustedProviders ?? ["github", "google"],
|
|
58
92
|
},
|
|
59
93
|
},
|
|
60
94
|
emailAndPassword: {
|
|
61
|
-
|
|
62
|
-
minPasswordLength: 12,
|
|
95
|
+
...emailAndPassword,
|
|
63
96
|
sendResetPassword: async ({ user, url }) => {
|
|
64
97
|
try {
|
|
65
98
|
const emailClient = getEmailClient();
|
|
@@ -87,12 +120,20 @@ function authOptions(pool) {
|
|
|
87
120
|
catch (error) {
|
|
88
121
|
logger.error("Failed to run user creator:", error);
|
|
89
122
|
}
|
|
123
|
+
if (cfg.onUserCreated) {
|
|
124
|
+
try {
|
|
125
|
+
await cfg.onUserCreated(user.id, user.name || user.email, user.email);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
logger.error("Failed to run onUserCreated callback:", error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
90
131
|
if (user.emailVerified)
|
|
91
132
|
return;
|
|
92
133
|
try {
|
|
93
134
|
await initVerificationSchema(pool);
|
|
94
135
|
const { token } = await generateVerificationToken(pool, user.id);
|
|
95
|
-
const verifyUrl = `${
|
|
136
|
+
const verifyUrl = `${baseURL}${basePath}/verify?token=${token}`;
|
|
96
137
|
const emailClient = getEmailClient();
|
|
97
138
|
const template = verifyEmailTemplate(verifyUrl, user.email);
|
|
98
139
|
await emailClient.send({
|
|
@@ -105,45 +146,30 @@ function authOptions(pool) {
|
|
|
105
146
|
catch (error) {
|
|
106
147
|
logger.error("Failed to send verification email:", error);
|
|
107
148
|
}
|
|
108
|
-
// Delegate personal tenant creation to the consumer
|
|
109
|
-
if (_config?.onUserCreated) {
|
|
110
|
-
try {
|
|
111
|
-
await _config.onUserCreated(user.id, user.name || user.email, user.email);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
logger.error("Failed to run onUserCreated callback:", error);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
149
|
},
|
|
118
150
|
},
|
|
119
151
|
},
|
|
120
152
|
},
|
|
121
153
|
session: {
|
|
122
|
-
cookieCache: { enabled: true, maxAge:
|
|
154
|
+
cookieCache: { enabled: true, maxAge: cfg.sessionCacheMaxAge ?? 300 },
|
|
123
155
|
},
|
|
124
156
|
advanced: {
|
|
125
|
-
cookiePrefix: "better-auth",
|
|
157
|
+
cookiePrefix: cfg.cookiePrefix || "better-auth",
|
|
126
158
|
cookies: {
|
|
127
159
|
session_token: {
|
|
128
|
-
attributes: {
|
|
129
|
-
domain: process.env.COOKIE_DOMAIN || ".wopr.bot",
|
|
130
|
-
},
|
|
160
|
+
attributes: cookieDomain ? { domain: cookieDomain } : {},
|
|
131
161
|
},
|
|
132
162
|
},
|
|
133
163
|
},
|
|
134
|
-
plugins: [twoFactor()],
|
|
164
|
+
plugins: cfg.twoFactor !== false ? [twoFactor()] : [],
|
|
135
165
|
rateLimit: {
|
|
136
166
|
enabled: true,
|
|
137
|
-
window: 60,
|
|
138
|
-
max: 100,
|
|
139
|
-
customRules: {
|
|
140
|
-
"/sign-in/email": { window: 900, max: 5 },
|
|
141
|
-
"/sign-up/email": { window: 3600, max: 10 },
|
|
142
|
-
"/request-password-reset": { window: 3600, max: 3 },
|
|
143
|
-
},
|
|
167
|
+
window: cfg.rateLimitWindow ?? 60,
|
|
168
|
+
max: cfg.rateLimitMax ?? 100,
|
|
169
|
+
customRules: { ...DEFAULT_RATE_LIMIT_RULES, ...cfg.rateLimitRules },
|
|
144
170
|
storage: "memory",
|
|
145
171
|
},
|
|
146
|
-
trustedOrigins
|
|
172
|
+
trustedOrigins,
|
|
147
173
|
};
|
|
148
174
|
}
|
|
149
175
|
/** Initialize Better Auth with the given config. Must be called before getAuth(). */
|
|
@@ -158,9 +184,11 @@ export async function runAuthMigrations() {
|
|
|
158
184
|
if (!_config)
|
|
159
185
|
throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
160
186
|
const { getMigrations } = (await import("better-auth/db"));
|
|
161
|
-
const { runMigrations } = await getMigrations(authOptions(_config
|
|
187
|
+
const { runMigrations } = await getMigrations(authOptions(_config));
|
|
162
188
|
await runMigrations();
|
|
163
|
-
|
|
189
|
+
if (_config.twoFactor !== false) {
|
|
190
|
+
await initTwoFactorSchema(_config.pool);
|
|
191
|
+
}
|
|
164
192
|
}
|
|
165
193
|
let _auth = null;
|
|
166
194
|
/**
|
|
@@ -171,7 +199,7 @@ export function getAuth() {
|
|
|
171
199
|
if (!_auth) {
|
|
172
200
|
if (!_config)
|
|
173
201
|
throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
174
|
-
_auth = betterAuth(authOptions(_config
|
|
202
|
+
_auth = betterAuth(authOptions(_config));
|
|
175
203
|
}
|
|
176
204
|
return _auth;
|
|
177
205
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
const mockCreateUserCreator = vi.fn();
|
|
3
|
+
vi.mock("./user-creator.js", () => ({
|
|
4
|
+
createUserCreator: (...args) => mockCreateUserCreator(...args),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock("../admin/role-store.js", () => ({
|
|
7
|
+
RoleStore: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const { getUserCreator, initBetterAuth, resetUserCreator } = await import("./better-auth.js");
|
|
10
|
+
const fakeConfig = { pool: {}, db: {} };
|
|
11
|
+
describe("getUserCreator", () => {
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
resetUserCreator();
|
|
14
|
+
mockCreateUserCreator.mockReset();
|
|
15
|
+
});
|
|
16
|
+
it("caches the resolved creator on success", async () => {
|
|
17
|
+
initBetterAuth(fakeConfig);
|
|
18
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
19
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
20
|
+
const first = await getUserCreator();
|
|
21
|
+
const second = await getUserCreator();
|
|
22
|
+
expect(first).toBe(second);
|
|
23
|
+
expect(mockCreateUserCreator).toHaveBeenCalledOnce();
|
|
24
|
+
});
|
|
25
|
+
it("clears cached promise on rejection so next call retries", async () => {
|
|
26
|
+
initBetterAuth(fakeConfig);
|
|
27
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
28
|
+
mockCreateUserCreator.mockRejectedValueOnce(new Error("DB unavailable"));
|
|
29
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
30
|
+
await expect(getUserCreator()).rejects.toThrow("DB unavailable");
|
|
31
|
+
const creator = await getUserCreator();
|
|
32
|
+
expect(creator).toBe(fakeCreator);
|
|
33
|
+
expect(mockCreateUserCreator).toHaveBeenCalledTimes(2);
|
|
34
|
+
});
|
|
35
|
+
});
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -184,3 +184,15 @@ export declare function validateTenantOwnership<T>(c: Context, resource: T | nul
|
|
|
184
184
|
* @returns true if the user has access, false otherwise.
|
|
185
185
|
*/
|
|
186
186
|
export declare function validateTenantAccess(userId: string, requestedTenantId: string | undefined, orgMemberRepo: IOrgMemberRepository): Promise<boolean>;
|
|
187
|
+
export type { IApiKeyRepository } from "./api-key-repository.js";
|
|
188
|
+
export { DrizzleApiKeyRepository } from "./api-key-repository.js";
|
|
189
|
+
export type { Auth, AuthRateLimitRule, BetterAuthConfig, OAuthProvider, } from "./better-auth.js";
|
|
190
|
+
export { getAuth, getEmailVerifier, getUserCreator, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
|
|
191
|
+
export type { ILoginHistoryRepository, LoginHistoryEntry } from "./login-history-repository.js";
|
|
192
|
+
export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
|
|
193
|
+
export type { SessionAuthEnv } from "./middleware.js";
|
|
194
|
+
export { dualAuth, sessionAuth } from "./middleware.js";
|
|
195
|
+
export type { IUserCreator } from "./user-creator.js";
|
|
196
|
+
export { createUserCreator } from "./user-creator.js";
|
|
197
|
+
export type { IUserRoleRepository } from "./user-role-repository.js";
|
|
198
|
+
export { DrizzleUserRoleRepository } from "./user-role-repository.js";
|
package/dist/auth/index.js
CHANGED
|
@@ -420,3 +420,10 @@ export async function validateTenantAccess(userId, requestedTenantId, orgMemberR
|
|
|
420
420
|
const member = await orgMemberRepo.findMember(requestedTenantId, userId);
|
|
421
421
|
return member !== null;
|
|
422
422
|
}
|
|
423
|
+
export { DrizzleApiKeyRepository } from "./api-key-repository.js";
|
|
424
|
+
// Test utilities — do not call in production code
|
|
425
|
+
export { getAuth, getEmailVerifier, getUserCreator, initBetterAuth, resetAuth, resetUserCreator, runAuthMigrations, setAuth, } from "./better-auth.js";
|
|
426
|
+
export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
|
|
427
|
+
export { dualAuth, sessionAuth } from "./middleware.js";
|
|
428
|
+
export { createUserCreator } from "./user-creator.js";
|
|
429
|
+
export { DrizzleUserRoleRepository } from "./user-role-repository.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mockCreateUserCreator = vi.fn();
|
|
4
|
+
vi.mock("./user-creator.js", () => ({
|
|
5
|
+
createUserCreator: (...args: unknown[]) => mockCreateUserCreator(...args),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock("../admin/role-store.js", () => ({
|
|
9
|
+
RoleStore: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const { getUserCreator, initBetterAuth, resetUserCreator } = await import("./better-auth.js");
|
|
13
|
+
|
|
14
|
+
const fakeConfig = { pool: {}, db: {} } as Parameters<typeof initBetterAuth>[0];
|
|
15
|
+
|
|
16
|
+
describe("getUserCreator", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetUserCreator();
|
|
19
|
+
mockCreateUserCreator.mockReset();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("caches the resolved creator on success", async () => {
|
|
23
|
+
initBetterAuth(fakeConfig);
|
|
24
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
25
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
26
|
+
|
|
27
|
+
const first = await getUserCreator();
|
|
28
|
+
const second = await getUserCreator();
|
|
29
|
+
|
|
30
|
+
expect(first).toBe(second);
|
|
31
|
+
expect(mockCreateUserCreator).toHaveBeenCalledOnce();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("clears cached promise on rejection so next call retries", async () => {
|
|
35
|
+
initBetterAuth(fakeConfig);
|
|
36
|
+
const fakeCreator = { createUser: vi.fn() };
|
|
37
|
+
|
|
38
|
+
mockCreateUserCreator.mockRejectedValueOnce(new Error("DB unavailable"));
|
|
39
|
+
mockCreateUserCreator.mockResolvedValueOnce(fakeCreator);
|
|
40
|
+
|
|
41
|
+
await expect(getUserCreator()).rejects.toThrow("DB unavailable");
|
|
42
|
+
|
|
43
|
+
const creator = await getUserCreator();
|
|
44
|
+
expect(creator).toBe(fakeCreator);
|
|
45
|
+
expect(mockCreateUserCreator).toHaveBeenCalledTimes(2);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -20,59 +20,150 @@ import { passwordResetEmailTemplate, verifyEmailTemplate } from "../email/templa
|
|
|
20
20
|
import { generateVerificationToken, initVerificationSchema, PgEmailVerifier } from "../email/verification.js";
|
|
21
21
|
import { createUserCreator, type IUserCreator } from "./user-creator.js";
|
|
22
22
|
|
|
23
|
+
/** OAuth provider credentials. */
|
|
24
|
+
export interface OAuthProvider {
|
|
25
|
+
clientId: string;
|
|
26
|
+
clientSecret: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rate limit rule for a specific auth endpoint. */
|
|
30
|
+
export interface AuthRateLimitRule {
|
|
31
|
+
window: number;
|
|
32
|
+
max: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/** Configuration for initializing Better Auth in platform-core. */
|
|
24
36
|
export interface BetterAuthConfig {
|
|
25
37
|
pool: Pool;
|
|
26
38
|
db: PlatformDb;
|
|
39
|
+
|
|
40
|
+
// --- Required ---
|
|
41
|
+
/** HMAC secret for session tokens. Falls back to BETTER_AUTH_SECRET env var. */
|
|
42
|
+
secret?: string;
|
|
43
|
+
/** Base URL for OAuth callbacks. Falls back to BETTER_AUTH_URL env var. */
|
|
44
|
+
baseURL?: string;
|
|
45
|
+
|
|
46
|
+
// --- Auth features ---
|
|
47
|
+
/** Route prefix. Default: "/api/auth" */
|
|
48
|
+
basePath?: string;
|
|
49
|
+
/** Email+password config. Default: enabled with 12-char min. */
|
|
50
|
+
emailAndPassword?: { enabled: boolean; minPasswordLength?: number };
|
|
51
|
+
/** OAuth providers. Default: reads GITHUB/DISCORD/GOOGLE env vars. */
|
|
52
|
+
socialProviders?: {
|
|
53
|
+
github?: OAuthProvider;
|
|
54
|
+
discord?: OAuthProvider;
|
|
55
|
+
google?: OAuthProvider;
|
|
56
|
+
};
|
|
57
|
+
/** Trusted providers for account linking. Default: ["github", "google"] */
|
|
58
|
+
trustedProviders?: string[];
|
|
59
|
+
/** Enable 2FA plugin. Default: true */
|
|
60
|
+
twoFactor?: boolean;
|
|
61
|
+
|
|
62
|
+
// --- Session & cookies ---
|
|
63
|
+
/** Cookie cache max age in seconds. Default: 300 (5 min) */
|
|
64
|
+
sessionCacheMaxAge?: number;
|
|
65
|
+
/** Cookie prefix. Default: "better-auth" */
|
|
66
|
+
cookiePrefix?: string;
|
|
67
|
+
/** Cookie domain (e.g., ".wopr.bot"). Falls back to COOKIE_DOMAIN env var. */
|
|
68
|
+
cookieDomain?: string;
|
|
69
|
+
|
|
70
|
+
// --- Rate limiting ---
|
|
71
|
+
/** Global rate limit window in seconds. Default: 60 */
|
|
72
|
+
rateLimitWindow?: number;
|
|
73
|
+
/** Global rate limit max requests. Default: 100 */
|
|
74
|
+
rateLimitMax?: number;
|
|
75
|
+
/** Per-endpoint rate limit overrides. Default: sign-in/sign-up/reset limits. */
|
|
76
|
+
rateLimitRules?: Record<string, AuthRateLimitRule>;
|
|
77
|
+
|
|
78
|
+
// --- Origins ---
|
|
79
|
+
/** Trusted origins for CORS. Falls back to UI_ORIGIN env var. */
|
|
80
|
+
trustedOrigins?: string[];
|
|
81
|
+
|
|
82
|
+
// --- Lifecycle hooks ---
|
|
27
83
|
/** Called after a new user signs up (e.g., create personal tenant). */
|
|
28
84
|
onUserCreated?: (userId: string, userName: string, email: string) => Promise<void>;
|
|
29
85
|
}
|
|
30
86
|
|
|
31
|
-
const
|
|
32
|
-
|
|
87
|
+
const DEFAULT_RATE_LIMIT_RULES: Record<string, AuthRateLimitRule> = {
|
|
88
|
+
"/sign-in/email": { window: 900, max: 5 },
|
|
89
|
+
"/sign-up/email": { window: 3600, max: 10 },
|
|
90
|
+
"/request-password-reset": { window: 3600, max: 3 },
|
|
91
|
+
};
|
|
33
92
|
|
|
34
93
|
let _config: BetterAuthConfig | null = null;
|
|
35
94
|
let _userCreator: IUserCreator | null = null;
|
|
36
95
|
let _userCreatorPromise: Promise<IUserCreator> | null = null;
|
|
37
96
|
|
|
38
|
-
async function getUserCreator(): Promise<IUserCreator> {
|
|
97
|
+
export async function getUserCreator(): Promise<IUserCreator> {
|
|
39
98
|
if (_userCreator) return _userCreator;
|
|
40
99
|
if (!_userCreatorPromise) {
|
|
41
100
|
if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
42
|
-
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
101
|
+
_userCreatorPromise = createUserCreator(new RoleStore(_config.db))
|
|
102
|
+
.then((creator) => {
|
|
103
|
+
_userCreator = creator;
|
|
104
|
+
return creator;
|
|
105
|
+
})
|
|
106
|
+
.catch((err) => {
|
|
107
|
+
_userCreatorPromise = null;
|
|
108
|
+
throw err;
|
|
109
|
+
});
|
|
46
110
|
}
|
|
47
111
|
return _userCreatorPromise;
|
|
48
112
|
}
|
|
49
113
|
|
|
50
|
-
|
|
114
|
+
/** Resolve OAuth providers from config or env vars. */
|
|
115
|
+
function resolveSocialProviders(cfg: BetterAuthConfig): BetterAuthOptions["socialProviders"] {
|
|
116
|
+
if (cfg.socialProviders) return cfg.socialProviders;
|
|
117
|
+
return {
|
|
118
|
+
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
119
|
+
? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
|
|
120
|
+
: {}),
|
|
121
|
+
...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
|
|
122
|
+
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
|
|
123
|
+
: {}),
|
|
124
|
+
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
125
|
+
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function authOptions(cfg: BetterAuthConfig): BetterAuthOptions {
|
|
131
|
+
const pool = cfg.pool;
|
|
132
|
+
const secret = cfg.secret || process.env.BETTER_AUTH_SECRET;
|
|
133
|
+
if (!secret) {
|
|
134
|
+
if (process.env.NODE_ENV === "production") {
|
|
135
|
+
throw new Error("BETTER_AUTH_SECRET is required in production");
|
|
136
|
+
}
|
|
137
|
+
logger.warn("BetterAuth secret not configured — sessions may be insecure");
|
|
138
|
+
}
|
|
139
|
+
const baseURL = cfg.baseURL || process.env.BETTER_AUTH_URL || "http://localhost:3100";
|
|
140
|
+
const basePath = cfg.basePath || "/api/auth";
|
|
141
|
+
const cookieDomain = cfg.cookieDomain || process.env.COOKIE_DOMAIN;
|
|
142
|
+
const trustedOrigins =
|
|
143
|
+
cfg.trustedOrigins ||
|
|
144
|
+
(process.env.UI_ORIGIN || "http://localhost:3001")
|
|
145
|
+
.split(",")
|
|
146
|
+
.map((o) => o.trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
// Default minPasswordLength: 12 — caller must explicitly override, not accidentally omit
|
|
149
|
+
const emailAndPassword = cfg.emailAndPassword
|
|
150
|
+
? { minPasswordLength: 12, ...cfg.emailAndPassword }
|
|
151
|
+
: { enabled: true, minPasswordLength: 12 };
|
|
152
|
+
|
|
51
153
|
return {
|
|
52
154
|
database: pool,
|
|
53
|
-
secret:
|
|
54
|
-
baseURL
|
|
55
|
-
basePath
|
|
56
|
-
socialProviders:
|
|
57
|
-
...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
58
|
-
? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } }
|
|
59
|
-
: {}),
|
|
60
|
-
...(process.env.DISCORD_CLIENT_ID && process.env.DISCORD_CLIENT_SECRET
|
|
61
|
-
? { discord: { clientId: process.env.DISCORD_CLIENT_ID, clientSecret: process.env.DISCORD_CLIENT_SECRET } }
|
|
62
|
-
: {}),
|
|
63
|
-
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
64
|
-
? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } }
|
|
65
|
-
: {}),
|
|
66
|
-
},
|
|
155
|
+
secret: secret || "",
|
|
156
|
+
baseURL,
|
|
157
|
+
basePath,
|
|
158
|
+
socialProviders: resolveSocialProviders(cfg),
|
|
67
159
|
account: {
|
|
68
160
|
accountLinking: {
|
|
69
161
|
enabled: true,
|
|
70
|
-
trustedProviders: ["github", "google"],
|
|
162
|
+
trustedProviders: cfg.trustedProviders ?? ["github", "google"],
|
|
71
163
|
},
|
|
72
164
|
},
|
|
73
165
|
emailAndPassword: {
|
|
74
|
-
|
|
75
|
-
minPasswordLength: 12,
|
|
166
|
+
...emailAndPassword,
|
|
76
167
|
sendResetPassword: async ({ user, url }) => {
|
|
77
168
|
try {
|
|
78
169
|
const emailClient = getEmailClient();
|
|
@@ -99,12 +190,20 @@ function authOptions(pool: Pool): BetterAuthOptions {
|
|
|
99
190
|
logger.error("Failed to run user creator:", error);
|
|
100
191
|
}
|
|
101
192
|
|
|
193
|
+
if (cfg.onUserCreated) {
|
|
194
|
+
try {
|
|
195
|
+
await cfg.onUserCreated(user.id, user.name || user.email, user.email);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logger.error("Failed to run onUserCreated callback:", error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
102
201
|
if (user.emailVerified) return;
|
|
103
202
|
|
|
104
203
|
try {
|
|
105
204
|
await initVerificationSchema(pool);
|
|
106
205
|
const { token } = await generateVerificationToken(pool, user.id);
|
|
107
|
-
const verifyUrl = `${
|
|
206
|
+
const verifyUrl = `${baseURL}${basePath}/verify?token=${token}`;
|
|
108
207
|
const emailClient = getEmailClient();
|
|
109
208
|
const template = verifyEmailTemplate(verifyUrl, user.email);
|
|
110
209
|
await emailClient.send({
|
|
@@ -116,45 +215,30 @@ function authOptions(pool: Pool): BetterAuthOptions {
|
|
|
116
215
|
} catch (error) {
|
|
117
216
|
logger.error("Failed to send verification email:", error);
|
|
118
217
|
}
|
|
119
|
-
|
|
120
|
-
// Delegate personal tenant creation to the consumer
|
|
121
|
-
if (_config?.onUserCreated) {
|
|
122
|
-
try {
|
|
123
|
-
await _config.onUserCreated(user.id, user.name || user.email, user.email);
|
|
124
|
-
} catch (error) {
|
|
125
|
-
logger.error("Failed to run onUserCreated callback:", error);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
218
|
},
|
|
129
219
|
},
|
|
130
220
|
},
|
|
131
221
|
},
|
|
132
222
|
session: {
|
|
133
|
-
cookieCache: { enabled: true, maxAge:
|
|
223
|
+
cookieCache: { enabled: true, maxAge: cfg.sessionCacheMaxAge ?? 300 },
|
|
134
224
|
},
|
|
135
225
|
advanced: {
|
|
136
|
-
cookiePrefix: "better-auth",
|
|
226
|
+
cookiePrefix: cfg.cookiePrefix || "better-auth",
|
|
137
227
|
cookies: {
|
|
138
228
|
session_token: {
|
|
139
|
-
attributes: {
|
|
140
|
-
domain: process.env.COOKIE_DOMAIN || ".wopr.bot",
|
|
141
|
-
},
|
|
229
|
+
attributes: cookieDomain ? { domain: cookieDomain } : {},
|
|
142
230
|
},
|
|
143
231
|
},
|
|
144
232
|
},
|
|
145
|
-
plugins: [twoFactor()],
|
|
233
|
+
plugins: cfg.twoFactor !== false ? [twoFactor()] : [],
|
|
146
234
|
rateLimit: {
|
|
147
235
|
enabled: true,
|
|
148
|
-
window: 60,
|
|
149
|
-
max: 100,
|
|
150
|
-
customRules: {
|
|
151
|
-
"/sign-in/email": { window: 900, max: 5 },
|
|
152
|
-
"/sign-up/email": { window: 3600, max: 10 },
|
|
153
|
-
"/request-password-reset": { window: 3600, max: 3 },
|
|
154
|
-
},
|
|
236
|
+
window: cfg.rateLimitWindow ?? 60,
|
|
237
|
+
max: cfg.rateLimitMax ?? 100,
|
|
238
|
+
customRules: { ...DEFAULT_RATE_LIMIT_RULES, ...cfg.rateLimitRules },
|
|
155
239
|
storage: "memory",
|
|
156
240
|
},
|
|
157
|
-
trustedOrigins
|
|
241
|
+
trustedOrigins,
|
|
158
242
|
};
|
|
159
243
|
}
|
|
160
244
|
|
|
@@ -174,9 +258,11 @@ export async function runAuthMigrations(): Promise<void> {
|
|
|
174
258
|
if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
175
259
|
type DbModule = { getMigrations: (opts: BetterAuthOptions) => Promise<{ runMigrations: () => Promise<void> }> };
|
|
176
260
|
const { getMigrations } = (await import("better-auth/db")) as unknown as DbModule;
|
|
177
|
-
const { runMigrations } = await getMigrations(authOptions(_config
|
|
261
|
+
const { runMigrations } = await getMigrations(authOptions(_config));
|
|
178
262
|
await runMigrations();
|
|
179
|
-
|
|
263
|
+
if (_config.twoFactor !== false) {
|
|
264
|
+
await initTwoFactorSchema(_config.pool);
|
|
265
|
+
}
|
|
180
266
|
}
|
|
181
267
|
|
|
182
268
|
let _auth: Auth | null = null;
|
|
@@ -188,7 +274,7 @@ let _auth: Auth | null = null;
|
|
|
188
274
|
export function getAuth(): Auth {
|
|
189
275
|
if (!_auth) {
|
|
190
276
|
if (!_config) throw new Error("BetterAuth not initialized — call initBetterAuth() first");
|
|
191
|
-
_auth = betterAuth(authOptions(_config
|
|
277
|
+
_auth = betterAuth(authOptions(_config));
|
|
192
278
|
}
|
|
193
279
|
return _auth;
|
|
194
280
|
}
|
package/src/auth/index.ts
CHANGED
|
@@ -518,3 +518,35 @@ export async function validateTenantAccess(
|
|
|
518
518
|
const member = await orgMemberRepo.findMember(requestedTenantId, userId);
|
|
519
519
|
return member !== null;
|
|
520
520
|
}
|
|
521
|
+
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
// Re-exports — Better Auth factory, middleware, and repositories
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
export type { IApiKeyRepository } from "./api-key-repository.js";
|
|
527
|
+
export { DrizzleApiKeyRepository } from "./api-key-repository.js";
|
|
528
|
+
export type {
|
|
529
|
+
Auth,
|
|
530
|
+
AuthRateLimitRule,
|
|
531
|
+
BetterAuthConfig,
|
|
532
|
+
OAuthProvider,
|
|
533
|
+
} from "./better-auth.js";
|
|
534
|
+
// Test utilities — do not call in production code
|
|
535
|
+
export {
|
|
536
|
+
getAuth,
|
|
537
|
+
getEmailVerifier,
|
|
538
|
+
getUserCreator,
|
|
539
|
+
initBetterAuth,
|
|
540
|
+
resetAuth,
|
|
541
|
+
resetUserCreator,
|
|
542
|
+
runAuthMigrations,
|
|
543
|
+
setAuth,
|
|
544
|
+
} from "./better-auth.js";
|
|
545
|
+
export type { ILoginHistoryRepository, LoginHistoryEntry } from "./login-history-repository.js";
|
|
546
|
+
export { BetterAuthLoginHistoryRepository } from "./login-history-repository.js";
|
|
547
|
+
export type { SessionAuthEnv } from "./middleware.js";
|
|
548
|
+
export { dualAuth, sessionAuth } from "./middleware.js";
|
|
549
|
+
export type { IUserCreator } from "./user-creator.js";
|
|
550
|
+
export { createUserCreator } from "./user-creator.js";
|
|
551
|
+
export type { IUserRoleRepository } from "./user-role-repository.js";
|
|
552
|
+
export { DrizzleUserRoleRepository } from "./user-role-repository.js";
|