& so it begins
This commit is contained in:
205
packages/logic/domains/auth/config.base.ts
Normal file
205
packages/logic/domains/auth/config.base.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
admin,
|
||||
customSession,
|
||||
magicLink,
|
||||
multiSession,
|
||||
username,
|
||||
} from "better-auth/plugins";
|
||||
import { getUserController, UserController } from "../user/controller";
|
||||
import { AuthController, getAuthController } from "./controller";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { FlowExecCtx } from "@/core/flow.execution.context";
|
||||
import { UserRoleMap } from "@domains/user/data";
|
||||
import { getRedisInstance } from "@pkg/redis";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { settings } from "@core/settings";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { logger } from "@pkg/logger";
|
||||
import { db, schema } from "@pkg/db";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// Constants
|
||||
const EMAIL_EXPIRES_IN_MINS = 10;
|
||||
const EMAIL_EXPIRES_IN_SECONDS = 60 * EMAIL_EXPIRES_IN_MINS;
|
||||
const COOKIE_CACHE_MAX_AGE = 60 * 5;
|
||||
|
||||
// Helper to create flow context for better-auth callbacks
|
||||
function createAuthFlowContext(contextLabel: string): FlowExecCtx {
|
||||
return {
|
||||
flowId: `auth:${contextLabel}:${nanoid(10)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton controller instances
|
||||
let authControllerInstance: AuthController | null = null;
|
||||
let userControllerInstance: UserController | null = null;
|
||||
|
||||
function getAuthControllerInstance(): AuthController {
|
||||
if (!authControllerInstance) {
|
||||
authControllerInstance = getAuthController();
|
||||
}
|
||||
return authControllerInstance;
|
||||
}
|
||||
|
||||
function getUserControllerInstance(): UserController {
|
||||
if (!userControllerInstance) {
|
||||
userControllerInstance = getUserController();
|
||||
}
|
||||
return userControllerInstance;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
trustedOrigins: ["http://localhost:5173", settings.betterAuthUrl],
|
||||
advanced: { useSecureCookies: settings.nodeEnv === "production" },
|
||||
appName: settings.appName,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
disableSignUp: true,
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
plugins: [
|
||||
customSession(async ({ user, session }) => {
|
||||
session.id = session.token;
|
||||
return { user, session };
|
||||
}),
|
||||
username({
|
||||
minUsernameLength: 5,
|
||||
maxUsernameLength: 20,
|
||||
usernameValidator: async (username) => {
|
||||
const fctx = createAuthFlowContext("username-check");
|
||||
const uc = getUserControllerInstance();
|
||||
|
||||
const result = await uc
|
||||
.isUsernameAvailable(fctx, username)
|
||||
.match(
|
||||
(isAvailable) => ({ success: true, isAvailable }),
|
||||
(error) => {
|
||||
logger.error(
|
||||
`[${fctx.flowId}] Failed to check username availability`,
|
||||
error,
|
||||
);
|
||||
return { success: false, isAvailable: false };
|
||||
},
|
||||
);
|
||||
|
||||
return result.isAvailable;
|
||||
},
|
||||
}),
|
||||
magicLink({
|
||||
expiresIn: EMAIL_EXPIRES_IN_SECONDS,
|
||||
rateLimit: { window: 60, max: 4 },
|
||||
sendMagicLink: async ({ email, token, url }, request) => {
|
||||
const fctx = createAuthFlowContext("magic-link");
|
||||
const ac = getAuthControllerInstance();
|
||||
|
||||
const result = await ac
|
||||
.sendMagicLink(fctx, email, token, url)
|
||||
.match(
|
||||
() => ({ success: true, error: undefined }),
|
||||
(error) => ({ success: false, error }),
|
||||
);
|
||||
|
||||
if (!result.success || result?.error) {
|
||||
logger.error(
|
||||
`[${fctx.flowId}] Failed to send magic link`,
|
||||
result.error,
|
||||
);
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: result.error?.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
admin({
|
||||
defaultRole: UserRoleMap.admin,
|
||||
defaultBanReason:
|
||||
"Stop fanum taxing the server bub, losing aura points fr",
|
||||
defaultBanExpiresIn: 60 * 60 * 24,
|
||||
}),
|
||||
multiSession({ maximumSessions: 5 }),
|
||||
],
|
||||
logger: {
|
||||
log: (level, message, metadata) => {
|
||||
logger.log(level, message, metadata);
|
||||
},
|
||||
level: settings.isDevelopment ? "debug" : "info",
|
||||
},
|
||||
database: drizzleAdapter(db, { provider: "pg", schema: { ...schema } }),
|
||||
secondaryStorage: {
|
||||
get: async (key) => {
|
||||
const redis = getRedisInstance();
|
||||
return await redis.get(key);
|
||||
},
|
||||
set: async (key, value, ttl) => {
|
||||
const redis = getRedisInstance();
|
||||
if (ttl) {
|
||||
await redis.setex(key, ttl, value);
|
||||
} else {
|
||||
await redis.set(key, value);
|
||||
}
|
||||
},
|
||||
delete: async (key) => {
|
||||
const redis = getRedisInstance();
|
||||
const out = await redis.del(key);
|
||||
if (!out && out !== 0) {
|
||||
return null;
|
||||
}
|
||||
return out.toString() as any;
|
||||
},
|
||||
},
|
||||
session: {
|
||||
modelName: "session",
|
||||
expiresIn: 60 * 60 * 24 * 7,
|
||||
updateAge: 60 * 60 * 24,
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: COOKIE_CACHE_MAX_AGE,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailVerification: async (
|
||||
{ user, newEmail, url, token },
|
||||
request,
|
||||
) => {
|
||||
const fctx = createAuthFlowContext("email-change");
|
||||
const ac = getAuthControllerInstance();
|
||||
|
||||
const result = await ac
|
||||
.sendEmailChangeVerificationEmail(
|
||||
fctx,
|
||||
newEmail,
|
||||
token,
|
||||
url,
|
||||
)
|
||||
.match(
|
||||
() => ({ success: true, error: undefined }),
|
||||
(error) => ({ success: false, error }),
|
||||
);
|
||||
|
||||
if (!result.success || result?.error) {
|
||||
logger.error(
|
||||
`[${fctx.flowId}] Failed to send email change verification`,
|
||||
result.error,
|
||||
);
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: result.error?.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
modelName: "user",
|
||||
additionalFields: {
|
||||
onboardingDone: {
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
},
|
||||
last2FAVerifiedAt: { type: "date", required: false },
|
||||
parentId: { required: false, type: "string" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// - - -
|
||||
148
packages/logic/domains/auth/controller.ts
Normal file
148
packages/logic/domains/auth/controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { AuthContext, MiddlewareContext, MiddlewareOptions } from "better-auth";
|
||||
import { AccountRepository } from "../user/account.repository";
|
||||
import { FlowExecCtx } from "@/core/flow.execution.context";
|
||||
import { ResultAsync } from "neverthrow";
|
||||
import type { Err } from "@pkg/result";
|
||||
import { authErrors } from "./errors";
|
||||
import { logger } from "@pkg/logger";
|
||||
import { nanoid } from "nanoid";
|
||||
import { db } from "@pkg/db";
|
||||
|
||||
export class AuthController {
|
||||
private readonly mins = 10;
|
||||
|
||||
constructor(private accountRepo: AccountRepository) {}
|
||||
|
||||
sendEmailChangeVerificationEmail(
|
||||
fctx: FlowExecCtx,
|
||||
newEmail: string,
|
||||
token: string,
|
||||
url: string,
|
||||
): ResultAsync<void, Err> {
|
||||
logger.info("Sending email change verification link", {
|
||||
...fctx,
|
||||
newEmail,
|
||||
});
|
||||
logger.debug("Original URL", { ...fctx, url });
|
||||
|
||||
const transformedUrl = url
|
||||
.replace("/api/auth/verify-email", "/account/verify-email")
|
||||
.replace("/api/", "/");
|
||||
|
||||
logger.debug("Transformed URL", { ...fctx, transformedUrl });
|
||||
|
||||
// Simulate email sending with 90/10 success/failure
|
||||
const success = Math.random() > 0.1;
|
||||
|
||||
if (!success) {
|
||||
logger.error("Failed to send email change verification link", {
|
||||
...fctx,
|
||||
error: "Simulated email service failure",
|
||||
});
|
||||
return ResultAsync.fromPromise(
|
||||
Promise.reject(
|
||||
authErrors.emailChangeVerificationFailed(
|
||||
fctx,
|
||||
"Simulated email service failure",
|
||||
),
|
||||
),
|
||||
(error) => error as Err,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Email change verification sent successfully", {
|
||||
...fctx,
|
||||
newEmail,
|
||||
});
|
||||
return ResultAsync.fromSafePromise(Promise.resolve(undefined));
|
||||
}
|
||||
|
||||
swapAccountPasswordForTwoFactor(
|
||||
fctx: FlowExecCtx,
|
||||
ctx: MiddlewareContext<
|
||||
MiddlewareOptions,
|
||||
AuthContext & { returned?: unknown; responseHeaders?: Headers }
|
||||
>,
|
||||
) {
|
||||
logger.info("Swapping account password for 2FA", {
|
||||
...fctx,
|
||||
});
|
||||
|
||||
if (!ctx.path.includes("two-factor")) {
|
||||
return ResultAsync.fromSafePromise(Promise.resolve(ctx));
|
||||
}
|
||||
|
||||
if (!ctx.body.password || ctx.body.password.length === 0) {
|
||||
return ResultAsync.fromSafePromise(Promise.resolve(ctx));
|
||||
}
|
||||
|
||||
logger.info("Rotating password for 2FA setup for user", {
|
||||
...fctx,
|
||||
userId: ctx.body.userId,
|
||||
});
|
||||
|
||||
return this.accountRepo
|
||||
.rotatePassword(fctx, ctx.body.userId, nanoid())
|
||||
.mapErr((err) => {
|
||||
logger.error("Failed to rotate password for 2FA", {
|
||||
...fctx,
|
||||
error: err,
|
||||
});
|
||||
return authErrors.passwordRotationFailed(fctx, err.detail);
|
||||
})
|
||||
.map((newPassword) => {
|
||||
logger.info("Password rotated successfully for 2FA setup", {
|
||||
...fctx,
|
||||
});
|
||||
return {
|
||||
...ctx,
|
||||
body: { ...ctx.body, password: newPassword },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
sendMagicLink(
|
||||
fctx: FlowExecCtx,
|
||||
email: string,
|
||||
token: string,
|
||||
url: string,
|
||||
): ResultAsync<void, Err> {
|
||||
logger.info("Sending magic link", { ...fctx, email });
|
||||
logger.debug("Original URL", { ...fctx, url });
|
||||
|
||||
const transformedUrl = url
|
||||
.replace("/api/auth/magic-link/verify", "/auth/magic-link")
|
||||
.replace("/api/", "/");
|
||||
|
||||
logger.debug("Transformed URL", { ...fctx, transformedUrl });
|
||||
|
||||
// Simulate email sending with 90/10 success/failure
|
||||
const success = Math.random() > 0.1;
|
||||
|
||||
if (!success) {
|
||||
logger.error("Failed to send magic link email", {
|
||||
...fctx,
|
||||
error: "Simulated email service failure",
|
||||
});
|
||||
return ResultAsync.fromPromise(
|
||||
Promise.reject(
|
||||
authErrors.magicLinkEmailFailed(
|
||||
fctx,
|
||||
"Simulated email service failure",
|
||||
),
|
||||
),
|
||||
(error) => error as Err,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Magic link email sent successfully", {
|
||||
...fctx,
|
||||
email,
|
||||
});
|
||||
return ResultAsync.fromSafePromise(Promise.resolve(undefined));
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthController(): AuthController {
|
||||
return new AuthController(new AccountRepository(db));
|
||||
}
|
||||
59
packages/logic/domains/auth/errors.ts
Normal file
59
packages/logic/domains/auth/errors.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { FlowExecCtx } from "@/core/flow.execution.context";
|
||||
import { getError } from "@pkg/logger";
|
||||
import { ERROR_CODES, type Err } from "@pkg/result";
|
||||
|
||||
export const authErrors = {
|
||||
emailSendFailed: (fctx: FlowExecCtx, detail: string): Err =>
|
||||
getError({
|
||||
flowId: fctx.flowId,
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to send email",
|
||||
description: "An error occurred while sending the email",
|
||||
detail,
|
||||
}),
|
||||
|
||||
magicLinkEmailFailed: (fctx: FlowExecCtx, detail: string): Err =>
|
||||
getError({
|
||||
flowId: fctx.flowId,
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to send magic link email",
|
||||
description: "An error occurred while sending the magic link",
|
||||
detail,
|
||||
}),
|
||||
|
||||
emailChangeVerificationFailed: (fctx: FlowExecCtx, detail: string): Err =>
|
||||
getError({
|
||||
flowId: fctx.flowId,
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to send email change verification link",
|
||||
description: "An error occurred while sending the verification email",
|
||||
detail,
|
||||
}),
|
||||
|
||||
passwordRotationFailed: (fctx: FlowExecCtx, detail: string): Err =>
|
||||
getError({
|
||||
flowId: fctx.flowId,
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to begin 2FA setup",
|
||||
description: "An error occurred while rotating the password for 2FA",
|
||||
detail,
|
||||
}),
|
||||
|
||||
dbError: (fctx: FlowExecCtx, detail: string): Err =>
|
||||
getError({
|
||||
flowId: fctx.flowId,
|
||||
code: ERROR_CODES.DATABASE_ERROR,
|
||||
message: "Database operation failed",
|
||||
description: "Please try again later",
|
||||
detail,
|
||||
}),
|
||||
|
||||
accountNotFound: (fctx: FlowExecCtx): Err =>
|
||||
getError({
|
||||
flowId: fctx.flowId,
|
||||
code: ERROR_CODES.NOT_FOUND,
|
||||
message: "Account not found",
|
||||
description: "Please try again later",
|
||||
detail: "Account not found for user",
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user