UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

345 lines (344 loc) • 12.7 kB
import { formCsrfMiddleware } from "../middlewares/origin-check.mjs"; import { parseUserOutput } from "../../db/schema.mjs"; import { setSessionCookie } from "../../cookies/index.mjs"; import { getAwaitableValue } from "../../context/helpers.mjs"; import { missingEmailLogMessage } from "../../oauth2/errors.mjs"; import { handleOAuthUserInfo } from "../../oauth2/link-account.mjs"; import { generateState } from "../../oauth2/state.mjs"; import { safeCloneRequest } from "../../utils/request.mjs"; import { createEmailVerificationToken } from "./email-verification.mjs"; import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error"; import { SocialProviderListEnum } from "@better-auth/core/social-providers"; import { createAuthEndpoint } from "@better-auth/core/api"; import * as z from "zod"; //#region src/api/routes/sign-in.ts const socialSignInBodySchema = z.object({ /** * Callback URL to redirect to after the user * has signed in. */ callbackURL: z.string().meta({ description: "Callback URL to redirect to after the user has signed in" }).optional(), /** * callback url to redirect if the user is newly registered. * * useful if you have different routes for existing users and new users */ newUserCallbackURL: z.string().optional(), /** * Callback url to redirect to if an error happens * * If it's initiated from the client sdk this defaults to * the current url. */ errorCallbackURL: z.string().meta({ description: "Callback URL to redirect to if an error happens" }).optional(), /** * OAuth2 provider to use` */ provider: SocialProviderListEnum, /** * Disable automatic redirection to the provider * * This is useful if you want to handle the redirection * yourself like in a popup or a different tab. */ disableRedirect: z.boolean().meta({ description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself" }).optional(), /** * ID token from the provider * * This is used to sign in the user * if the user is already signed in with the * provider in the frontend. * * Only applicable if the provider supports * it. Currently only `apple` and `google` is * supported out of the box. */ idToken: z.optional(z.object({ /** * ID token from the provider */ token: z.string().meta({ description: "ID token from the provider" }), /** * The nonce used to generate the token */ nonce: z.string().meta({ description: "Nonce used to generate the token" }).optional(), /** * Access token from the provider */ accessToken: z.string().meta({ description: "Access token from the provider" }).optional(), /** * Refresh token from the provider */ refreshToken: z.string().meta({ description: "Refresh token from the provider" }).optional(), /** * Expiry date of the token */ expiresAt: z.number().meta({ description: "Expiry date of the token" }).optional(), /** * The user object from the provider. * This is only available for some providers like Apple. */ user: z.object({ name: z.object({ firstName: z.string().optional(), lastName: z.string().optional() }).optional(), email: z.string().optional() }).meta({ description: "The user object from the provider. Only available for some providers like Apple." }).optional() })), scopes: z.array(z.string()).meta({ description: "Array of scopes to request from the provider. This will override the default scopes passed." }).optional(), /** * Explicitly request sign-up * * Should be used to allow sign up when * disableImplicitSignUp for this provider is * true */ requestSignUp: z.boolean().meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional(), /** * The login hint to use for the authorization code request */ loginHint: z.string().meta({ description: "The login hint to use for the authorization code request" }).optional(), /** * Additional data to be passed through the OAuth flow */ additionalData: z.record(z.string(), z.any()).optional().meta({ description: "Additional data to be passed through the OAuth flow" }) }); const signInSocial = () => createAuthEndpoint("/sign-in/social", { method: "POST", operationId: "socialSignIn", body: socialSignInBodySchema, metadata: { $Infer: { body: {}, returned: {} }, openapi: { description: "Sign in with a social provider", operationId: "socialSignIn", responses: { "200": { description: "Success - Returns session details (idToken branch) or an authorize URL (redirect branch)", content: { "application/json": { schema: { type: "object", description: "Returns session details when idToken is provided, or an authorize URL otherwise", properties: { token: { type: "string" }, user: { type: "object", $ref: "#/components/schemas/User" }, url: { type: "string" }, redirect: { type: "boolean" } }, required: ["redirect"] } } } } } } } }, async (c) => { const provider = await getAwaitableValue(c.context.socialProviders, { value: c.body.provider }); if (!provider) { c.context.logger.error("Provider not found. Make sure to add the provider in your auth config", { provider: c.body.provider }); throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.PROVIDER_NOT_FOUND); } if (c.body.idToken) { if (!provider.verifyIdToken) { c.context.logger.error("Provider does not support id token verification", { provider: c.body.provider }); throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED); } const { token, nonce } = c.body.idToken; if (!await provider.verifyIdToken(token, nonce, c)) { c.context.logger.warn("Invalid id token", { provider: c.body.provider }); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_TOKEN); } const userInfo = await provider.getUserInfo({ idToken: token, accessToken: c.body.idToken.accessToken, refreshToken: c.body.idToken.refreshToken, user: c.body.idToken.user }); if (!userInfo || !userInfo?.user) { c.context.logger.error("Failed to get user info", { provider: c.body.provider }); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO); } if (!userInfo.user.email) { c.context.logger.error(missingEmailLogMessage(c.body.provider, { source: "id_token" }), { provider: c.body.provider }); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.USER_EMAIL_NOT_FOUND); } const data = await handleOAuthUserInfo(c, { userInfo: { ...userInfo.user, email: userInfo.user.email, id: String(userInfo.user.id), name: userInfo.user.name || "", image: userInfo.user.image, emailVerified: userInfo.user.emailVerified || false }, account: { providerId: provider.id, accountId: String(userInfo.user.id), accessToken: c.body.idToken.accessToken }, callbackURL: c.body.callbackURL, disableSignUp: provider.disableImplicitSignUp && !c.body.requestSignUp || provider.disableSignUp }); if (data.error) throw APIError.from("UNAUTHORIZED", { message: data.error, code: "OAUTH_LINK_ERROR" }); await setSessionCookie(c, data.data); return c.json({ redirect: false, token: data.data.session.token, url: void 0, user: parseUserOutput(c.context.options, data.data.user) }); } const { codeVerifier, state } = await generateState(c, void 0, c.body.additionalData); const url = await provider.createAuthorizationURL({ state, codeVerifier, redirectURI: `${c.context.baseURL}/callback/${provider.id}`, scopes: c.body.scopes, loginHint: c.body.loginHint }); if (!c.body.disableRedirect) c.setHeader("Location", url.toString()); return c.json({ url: url.toString(), redirect: !c.body.disableRedirect }); }); const signInEmail = () => createAuthEndpoint("/sign-in/email", { method: "POST", operationId: "signInEmail", use: [formCsrfMiddleware], cloneRequest: true, body: z.object({ /** * Email of the user */ email: z.string().meta({ description: "Email of the user" }), /** * Password of the user */ password: z.string().meta({ description: "Password of the user" }), /** * Callback URL to use as a redirect for email * verification and for possible redirects */ callbackURL: z.string().meta({ description: "Callback URL to use as a redirect for email verification" }).optional(), /** * If this is false, the session will not be remembered * @default true */ rememberMe: z.boolean().meta({ description: "If this is false, the session will not be remembered. Default is `true`." }).default(true).optional() }), metadata: { allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"], $Infer: { body: {}, returned: {} }, openapi: { operationId: "signInEmail", description: "Sign in with email and password", responses: { "200": { description: "Success - Returns either session details or redirect URL", content: { "application/json": { schema: { type: "object", description: "Session response when idToken is provided", properties: { redirect: { type: "boolean", enum: [false] }, token: { type: "string", description: "Session token" }, url: { type: "string", nullable: true }, user: { type: "object", $ref: "#/components/schemas/User" } }, required: [ "redirect", "token", "user" ] } } } } } } } }, async (ctx) => { if (!ctx.context.options?.emailAndPassword?.enabled) { ctx.context.logger.error("Email and password is not enabled. Make sure to enable it in the options on you `auth.ts` file. Check `https://better-auth.com/docs/authentication/email-password` for more!"); throw APIError.from("BAD_REQUEST", { code: "EMAIL_PASSWORD_DISABLED", message: "Email and password is not enabled" }); } const { email, password } = ctx.body; if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL); const user = await ctx.context.internalAdapter.findUserByEmail(email, { includeAccounts: true }); if (!user) { await ctx.context.password.hash(password); ctx.context.logger.warn("User not found"); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_EMAIL_OR_PASSWORD); } const credentialAccount = user.accounts.find((a) => a.providerId === "credential"); if (!credentialAccount) { await ctx.context.password.hash(password); ctx.context.logger.warn("Credential account not found"); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_EMAIL_OR_PASSWORD); } const currentPassword = credentialAccount?.password; if (!currentPassword) { await ctx.context.password.hash(password); ctx.context.logger.warn("Password not found"); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_EMAIL_OR_PASSWORD); } if (!await ctx.context.password.verify({ hash: currentPassword, password })) { ctx.context.logger.warn("Invalid password"); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.INVALID_EMAIL_OR_PASSWORD); } if (ctx.context.options?.emailAndPassword?.requireEmailVerification && !user.user.emailVerified) { if (!ctx.context.options?.emailVerification?.sendVerificationEmail) throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.EMAIL_NOT_VERIFIED); if (ctx.context.options?.emailVerification?.sendOnSignIn) { const token = await createEmailVerificationToken(ctx.context.secret, user.user.email, void 0, ctx.context.options.emailVerification?.expiresIn); const callbackURL = ctx.body.callbackURL ? encodeURIComponent(ctx.body.callbackURL) : encodeURIComponent("/"); const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`; await ctx.context.runInBackgroundOrAwait(ctx.context.options.emailVerification.sendVerificationEmail({ user: user.user, url, token }, safeCloneRequest(ctx.request))); } throw APIError.from("FORBIDDEN", BASE_ERROR_CODES.EMAIL_NOT_VERIFIED); } const session = await ctx.context.internalAdapter.createSession(user.user.id, ctx.body.rememberMe === false); if (!session) { ctx.context.logger.error("Failed to create session"); throw APIError.from("UNAUTHORIZED", BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION); } await setSessionCookie(ctx, { session, user: user.user }, ctx.body.rememberMe === false); if (ctx.body.callbackURL) ctx.setHeader("Location", ctx.body.callbackURL); return ctx.json({ redirect: !!ctx.body.callbackURL, token: session.token, url: ctx.body.callbackURL, user: parseUserOutput(ctx.context.options, user.user) }); }); //#endregion export { signInEmail, signInSocial };