better-auth-credentials-plugin
Version:
Generic credentials authentication plugin for Better Auth
268 lines (267 loc) • 13.7 kB
JavaScript
// Adaptado de https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/plugins/username/index.ts
// e https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/api/routes/sign-in.ts
import { APIError } from "better-call";
import { createAuthEndpoint, sendVerificationEmailFn } from "better-auth/api";
import { CREDENTIALS_ERROR_CODES as CREDENTIALS_ERROR_CODES } from "./error-codes.js";
import { setSessionCookie } from "better-auth/cookies";
import { default as z } from "zod";
const defaultCredentialsSchema = z.object({
email: z.string({
description: "The email of the user",
}).min(1).email(),
password: z.string({
description: "The password of the user",
}).min(1),
rememberMe: z.boolean({
description: "Remember the user session",
}).optional(),
});
export const credentials = (options) => {
const zodSchema = options.inputSchema || defaultCredentialsSchema;
return {
id: "credentials",
endpoints: {
signInUsername: createAuthEndpoint(options.path || "/sign-in/credentials", {
method: "POST",
body: zodSchema,
metadata: {
openapi: {
summary: "Sign in with Credentials",
description: "Sign in with credentials using the user's email and password or other configured fields.",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: {
type: "object",
properties: {
token: {
type: "string",
description: "Session token for the authenticated session",
},
user: {
$ref: "#/components/schemas/User",
},
},
required: ["token", "user"],
},
},
},
},
},
},
},
}, async (ctx) => {
// The zod schema on body already validated the input
const parsed = ctx.body;
// ================== Authenticate with Credentials ===================
let callbackResult;
try {
callbackResult = await options.callback(ctx, parsed);
if (!callbackResult) {
ctx.context.logger.error("Authentication failed, callback didn't returned user data", { credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
}
catch (error) {
ctx.context.logger.error("Authentication failed", { error, credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
let { onSignIn, onSignUp, email, ..._userData } = callbackResult;
let userData = _userData;
// Fallback email from body if not provided in callback result
if (!email) {
email = "email" in parsed && typeof parsed.email === "string" ? parsed.email : undefined;
if (!email) {
ctx.context.logger.error("Email is required for credentials authentication", { credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
}
// ================== Find User & Account, also Auto-SignUp if enabled ===================
let user = await ctx.context.adapter.findOne({
model: "user",
where: [
{
field: "email",
value: email,
},
],
});
// If no user is found and autoSignUp is not enabled, throw an error
if (!options.autoSignUp && !user) {
// TODO: timing attack mitigation
ctx.context.logger.error("User not found", { credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
let account = null;
if (!user) {
// Auto-SignUp: Create a new user and account
try {
if (onSignUp && typeof onSignUp === "function") {
userData = await onSignUp({ email: email, ...userData });
}
if (!userData || !email) {
throw new APIError("UNPROCESSABLE_ENTITY", {
message: CREDENTIALS_ERROR_CODES.EMAIL_REQUIRED,
details: "User data must include at least email",
});
}
delete userData.email;
const { name, ...restUserData } = userData;
user = await ctx.context.internalAdapter.createUser({
email: email,
name: name || email, // Fallback to using email as name if not provided
...restUserData
}, ctx);
}
catch (e) {
ctx.context.logger.error("Failed to create user", e);
if (e instanceof APIError) {
throw e;
}
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
if (!user) {
throw new APIError("BAD_REQUEST", {
message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR,
});
}
// Create an account for the user
account = await ctx.context.internalAdapter.linkAccount({
userId: user.id,
providerId: options.providerId || "credential",
accountId: user.id,
}, ctx);
// If the user is created, we can send the verification email if required
// In this case, just return the user without a token and no session is created (this mimics the behavior of the email and password sign-up flow)
if (!user.emailVerified &&
(ctx.context.options.emailVerification?.sendOnSignUp ||
ctx.context.options.emailAndPassword?.requireEmailVerification)) {
await sendVerificationEmailFn(ctx, user);
return ctx.json({
token: null,
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
});
}
}
else {
// Sign-in: Get the user account with the chosen provider
account = await ctx.context.adapter.findOne({
model: "account",
where: [
{
field: "userId",
value: user.id,
},
{
field: "providerId",
value: options.providerId || "credential",
},
],
});
if (!options.linkAccountIfExisting) {
if (!account) {
ctx.context.logger.error("User exists but no account found for this provider", { credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
// Prevent email & password created users from logging in with this credentials plugin, as they would have a password set
if (account?.password) {
ctx.context.logger.error("Shouldn't login with credentials, this user has a account with password", { credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
}
else {
if (!options.autoSignUp && !account) {
ctx.context.logger.error("Account for this provider not found", { credentials });
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
if (!account) {
// Create an account for the user
account = await ctx.context.internalAdapter.linkAccount({
userId: user.id,
providerId: options.providerId || "credential",
accountId: user.id,
}, ctx);
}
}
if (!user.emailVerified &&
ctx.context.options.emailAndPassword?.requireEmailVerification) {
await sendVerificationEmailFn(ctx, user);
throw new APIError("FORBIDDEN", {
message: CREDENTIALS_ERROR_CODES.EMAIL_NOT_VERIFIED,
});
}
try {
if (onSignIn && typeof onSignIn === "function") {
userData = await onSignIn({ email: email, ...userData }, user, account);
}
}
catch (e) {
ctx.context.logger.error("Failed to update user data on sign in", e);
if (e instanceof APIError) {
throw e;
}
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.INVALID_CREDENTIALS,
});
}
// Update the user with the new data (excluding email)
if (userData) {
delete userData.email;
if (Object.keys(userData).length > 0) {
user = (await ctx.context.internalAdapter.updateUser(user.id, userData, ctx));
}
}
}
// ================== Authenticated! Proceed with login flow ===================
const rememberMe = "rememberMe" in parsed ? parsed.rememberMe : false;
const session = await ctx.context.internalAdapter.createSession(user.id, ctx, rememberMe === false);
if (!session) {
ctx.context.logger.error("Failed to create session");
throw new APIError("UNAUTHORIZED", {
message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR
});
}
await setSessionCookie(ctx, { session, user }, rememberMe === false);
return ctx.json({
token: session.token,
user: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
emailVerified: user.emailVerified,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
});
}),
},
$ERROR_CODES: CREDENTIALS_ERROR_CODES,
};
};