UNPKG

better-auth-credentials-plugin

Version:

Generic credentials authentication plugin for Better Auth (To auth with ldap, external API, etc...)

335 lines (334 loc) 17.9 kB
// 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 { defaultCredentialsSchema } from "./schema.js"; /** * Customized Credentials plugin for BetterAuth. * * The options allow you to customize the input schema, the callback function, and other behaviors. * * Summary of the stages of this authentication flow: * 1. Validate the input data against `inputSchema` * 2. Call the `callback` function * - If the callback throws an error, or doesn't return a object with user data, a generic 401 Unauthorized error is thrown. * 3. Find the user by email (given by callback or parsed input), if exists proceed to [SIGN IN], if not [SIGN UP] (only when `autoSignUp` is true). * * **[SIGN IN]** * * 4. Find the Account with the providerId * - If the account is not found, and `linkAccountIfExisting` or `autoSignUp` is false, login fails with a 401 Unauthorized error. * 5. If provided, Call the `onSignIn` callback function, but yet don't update the user data. * 6. If no Account was found on step 4. call the `onLinkAccount` callback function to get the account data to be stored, and then create a new Account for the user with the providerId. * 7. Update the user with the provided data (Either returned by the auth callback function or the `onSignIn` callback function). * * **[SIGN UP]** * * 4. If provided, call the `onSignUp` callback function to get the user data to be stored. * 5. Create a new User with the provided data (Either returned by the auth callback function or the `onSignUp` callback function). * 5. If provided, call the `onLinkAccount` callback function to get the account data to be stored * 6. Then create a new Account for the user with the providerId. * * **[AUTHENTICATED!]** * * 6. Create a new session for the user and set the session cookie. * 7. Return the user data and the session token. * * @example * ```ts * credentials({ * autoSignUp: true, * callback: async (ctx, parsed) => { * // 1. Verify the credentials * * // 2. On success, return the user data * return { * email: parsed.email * }; * }) */ export const credentials = (options) => { const zodSchema = (options.inputSchema || defaultCredentialsSchema); return { id: "credentials", endpoints: { signInUsername: createAuthEndpoint( // Endpoints are inferred from the server plugin by adding a $InferServerPlugin key to the client plugin. // Without this 'as' key the inferred client plugin would not work properly. (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) => { // ================== 1. Validate the input data =================== // TODO: double check if the body was *really* parsed against the zod schema const parsed = ctx.body; // ================== 2. Calling Callback Function =================== 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, onLinkAccount, 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("UNPROCESSABLE_ENTITY", { message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR, details: "Email is required for credentials authentication", }); } } email = email.toLowerCase(); // ================== 3. Find User by email =================== 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, }); } // If email verification is required, return early if (user && !user.emailVerified && ctx.context.options.emailAndPassword?.requireEmailVerification) { await sendVerificationEmailFn(ctx, user); throw new APIError("FORBIDDEN", { message: CREDENTIALS_ERROR_CODES.EMAIL_NOT_VERIFIED, }); } let account = null; if (!user) { // =================================================================== // = SIGN UP = // = Create a new User and Account, for this provider = // =================================================================== // // ================== 4. create new User ==================== try { if (onSignUp && typeof onSignUp === "function") { const newData = await onSignUp({ email: email, ...userData }); if (!newData) { throw new Error("onSignUp callback returned null, failed sign up"); } userData = newData; } 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, // Yes, the type is wrong, NAME IS OPTIONAL emailVerified: false, ...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("UNPROCESSABLE_ENTITY", { message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR, }); } // ================== 5. create new Account ==================== let accountData = {}; if (onLinkAccount && typeof onLinkAccount === "function") { accountData = await onLinkAccount(user); } account = await ctx.context.internalAdapter.linkAccount({ userId: user.id, providerId: options.providerId || "credential", accountId: user.id, ...accountData }, ctx); // If the user is created, we can send the verification email if required if (!user.emailVerified && (ctx.context.options.emailVerification?.sendOnSignUp || ctx.context.options.emailAndPassword?.requireEmailVerification)) { await sendVerificationEmailFn(ctx, user); // If email verification is required, 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 (ctx.context.options.emailAndPassword?.requireEmailVerification) { 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 = // = Find/Link Account, for this provider = // =================================================================== // // =============== 4. 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.autoSignUp || !options.linkAccountIfExisting) && !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, }); } if (account && account.providerId === "credential" && 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, }); } // =============== 5. Update user data ============== try { if (onSignIn && typeof onSignIn === "function") { const newData = await onSignIn({ email: email, ...userData }, user, account); if (!newData) { throw new Error("onSignIn callback returned null, failed on sign in"); } userData = newData; } } 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, }); } // Doing the linking after onSignIn callback, so if it fails no account is created if (!account) { // Create an account for the user if it doesn't exist let accountData = {}; if (onLinkAccount && typeof onLinkAccount === "function") { accountData = await onLinkAccount(user); } account = await ctx.context.internalAdapter.linkAccount({ userId: user.id, providerId: options.providerId || "credential", accountId: user.id, ...accountData }, ctx); } // 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("BAD_REQUEST", { message: CREDENTIALS_ERROR_CODES.UNEXPECTED_ERROR }); } await setSessionCookie(ctx, { session, user }, rememberMe === false); // =============== Response with user data ============== // TODO: how to return all fields with { returned: true } configured? 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, }; };