better-auth
Version:
The most comprehensive authentication framework for TypeScript.
345 lines (344 loc) • 12.7 kB
JavaScript
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 };