better-auth
Version:
The most comprehensive authentication framework for TypeScript.
276 lines (275 loc) • 10 kB
JavaScript
import { isAPIError } from "../../utils/is-api-error.mjs";
import { mergeSchema } from "../../db/schema.mjs";
import { setSessionCookie } from "../../cookies/index.mjs";
import { APIError } from "../../api/index.mjs";
import { PACKAGE_VERSION } from "../../version.mjs";
import { toChecksumAddress } from "../../utils/hashing.mjs";
import { normalizeSiweDomain, parseSiweMessage } from "./parse-message.mjs";
import { schema } from "./schema.mjs";
import { createLocalAccountIssuer } from "@better-auth/core/db";
import { createAuthEndpoint } from "@better-auth/core/api";
import * as z from "zod";
import { createPlaceholderEmail } from "@better-auth/core/utils/email";
//#region src/plugins/siwe/index.ts
const signedWalletAddressSchema = z.string().regex(/^0x[a-fA-F0-9]{40}$/).length(42);
const SIWE_VERIFICATION_IDENTIFIER_PREFIX = "siwe:";
const SIWE_NONCE_MAX_LENGTH = 250;
const SIWE_NONCE_ALPHANUMERIC_REGEX = /^[a-zA-Z0-9]+$/;
const isValidSiweNonce = (nonce) => typeof nonce === "string" && nonce.length >= 8 && nonce.length <= SIWE_NONCE_MAX_LENGTH && SIWE_NONCE_ALPHANUMERIC_REGEX.test(nonce);
const getSiweNonceBodySchema = z.object({}).strict().optional();
const siwe = (options) => {
const createSiweNonceEndpoint = (path) => createAuthEndpoint(path, {
method: "POST",
body: getSiweNonceBodySchema
}, async (ctx) => {
const nonce = await options.getNonce();
if (!isValidSiweNonce(nonce)) throw APIError.fromStatus("INTERNAL_SERVER_ERROR", {
message: `SIWE getNonce must return an ERC-4361 nonce: 8-${SIWE_NONCE_MAX_LENGTH} alphanumeric characters.`,
status: 500,
code: "SIWE_INVALID_NONCE"
});
await ctx.context.internalAdapter.createVerificationValue({
identifier: `${SIWE_VERIFICATION_IDENTIFIER_PREFIX}${nonce}`,
value: nonce,
expiresAt: new Date(Date.now() + 900 * 1e3)
});
return ctx.json({ nonce });
});
return {
id: "siwe",
version: PACKAGE_VERSION,
schema: mergeSchema(schema, options?.schema),
endpoints: {
getSiweNonce: createSiweNonceEndpoint("/siwe/nonce"),
getNonce: createSiweNonceEndpoint("/siwe/get-nonce"),
verifySiweMessage: createAuthEndpoint("/siwe/verify", {
method: "POST",
body: z.object({
message: z.string().min(1),
signature: z.string().min(1),
email: z.email().optional()
}).strict().refine((data) => options.anonymous !== false || !!data.email, {
message: "Email is required when the anonymous plugin option is disabled.",
path: ["email"]
}),
requireRequest: true
}, async (ctx) => {
const { message, signature, email } = ctx.body;
const isAnon = options.anonymous ?? true;
if (!isAnon && !email) throw APIError.fromStatus("BAD_REQUEST", {
message: "Email is required when anonymous is disabled.",
status: 400
});
try {
const parsedMessage = parseSiweMessage(message);
if (!isValidSiweNonce(parsedMessage.nonce)) throw APIError.fromStatus("UNAUTHORIZED", {
message: "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID",
status: 401,
code: "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH"
});
if (!await ctx.context.internalAdapter.consumeVerificationValue(`${SIWE_VERIFICATION_IDENTIFIER_PREFIX}${parsedMessage.nonce}`)) throw APIError.fromStatus("UNAUTHORIZED", {
message: "Unauthorized: Invalid or expired nonce",
status: 401,
code: "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"
});
const nonce = parsedMessage.nonce;
const parsedWalletAddress = signedWalletAddressSchema.safeParse(parsedMessage.address);
const walletAddress = parsedWalletAddress.success ? toChecksumAddress(parsedWalletAddress.data) : null;
const chainId = parsedMessage.chainId;
const domainMatches = !!parsedMessage.domain && normalizeSiweDomain(parsedMessage.domain) === normalizeSiweDomain(options.domain);
if (!walletAddress || typeof chainId !== "number" || chainId <= 0 || !domainMatches) throw APIError.fromStatus("UNAUTHORIZED", {
message: "Unauthorized: SIWE message does not match the expected nonce, domain, address, or chain ID",
status: 401,
code: "UNAUTHORIZED_SIWE_MESSAGE_MISMATCH"
});
const now = Date.now();
if (parsedMessage.expirationTime) {
const expiresAt = Date.parse(parsedMessage.expirationTime);
if (!Number.isNaN(expiresAt) && now >= expiresAt) throw APIError.fromStatus("UNAUTHORIZED", {
message: "Unauthorized: SIWE message has expired",
status: 401,
code: "UNAUTHORIZED_SIWE_MESSAGE_EXPIRED"
});
}
if (parsedMessage.notBefore) {
const notBefore = Date.parse(parsedMessage.notBefore);
if (!Number.isNaN(notBefore) && now < notBefore) throw APIError.fromStatus("UNAUTHORIZED", {
message: "Unauthorized: SIWE message is not yet valid",
status: 401,
code: "UNAUTHORIZED_SIWE_MESSAGE_NOT_YET_VALID"
});
}
if (!await options.verifyMessage({
message,
signature,
address: walletAddress,
chainId,
cacao: {
h: { t: "caip122" },
p: {
domain: options.domain,
aud: options.domain,
nonce,
iss: options.domain,
version: "1"
},
s: {
t: "eip191",
s: signature
}
}
})) throw APIError.fromStatus("UNAUTHORIZED", {
message: "Unauthorized: Invalid SIWE signature",
status: 401
});
let user = null;
const existingWalletAddress = await ctx.context.adapter.findOne({
model: "walletAddress",
where: [{
field: "address",
operator: "eq",
value: walletAddress
}, {
field: "chainId",
operator: "eq",
value: chainId
}]
});
if (existingWalletAddress) user = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "id",
operator: "eq",
value: existingWalletAddress.userId
}]
});
else {
const anyWalletAddress = await ctx.context.adapter.findOne({
model: "walletAddress",
where: [{
field: "address",
operator: "eq",
value: walletAddress
}]
});
if (anyWalletAddress) user = await ctx.context.adapter.findOne({
model: "user",
where: [{
field: "id",
operator: "eq",
value: anyWalletAddress.userId
}]
});
}
if (!user) {
const normalizedEmail = email?.toLowerCase();
const walletEmail = options.emailDomainName ? `${walletAddress}@${options.emailDomainName}` : createPlaceholderEmail({
identifier: walletAddress,
namespace: "siwe"
});
let userEmail = walletEmail;
let emailClaimIdentifier;
if (!isAnon && normalizedEmail) {
const identifier = `siwe-email-claim-${normalizedEmail}`;
let reserved = false;
try {
reserved = await ctx.context.internalAdapter.reserveVerificationValue({
identifier,
value: walletAddress,
expiresAt: new Date(Date.now() + 6e4)
});
} catch {
reserved = false;
}
if (reserved) {
emailClaimIdentifier = identifier;
if (!await ctx.context.internalAdapter.findUserByEmail(normalizedEmail)) userEmail = normalizedEmail;
}
}
const { name, avatar } = await options.ensLookup?.({ walletAddress }) ?? {};
const createSIWEUser = (email) => ctx.context.internalAdapter.createUser({
name: name ?? walletAddress,
email,
image: avatar ?? ""
}, { method: "siwe" });
try {
user = await createSIWEUser(userEmail);
} catch (error) {
if (userEmail !== normalizedEmail || !normalizedEmail) throw error;
if (!await ctx.context.internalAdapter.findUserByEmail(normalizedEmail)) throw error;
userEmail = walletEmail;
user = await createSIWEUser(userEmail);
} finally {
if (emailClaimIdentifier) await ctx.context.internalAdapter.consumeVerificationValue(emailClaimIdentifier).catch(() => {});
}
await ctx.context.adapter.create({
model: "walletAddress",
data: {
userId: user.id,
address: walletAddress,
chainId,
isPrimary: true,
createdAt: /* @__PURE__ */ new Date()
}
});
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: "siwe",
issuer: createLocalAccountIssuer("siwe"),
accountId: `${walletAddress}:${chainId}`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
} else if (!existingWalletAddress) {
await ctx.context.adapter.create({
model: "walletAddress",
data: {
userId: user.id,
address: walletAddress,
chainId,
isPrimary: false,
createdAt: /* @__PURE__ */ new Date()
}
});
await ctx.context.internalAdapter.createAccount({
userId: user.id,
providerId: "siwe",
issuer: createLocalAccountIssuer("siwe"),
accountId: `${walletAddress}:${chainId}`,
createdAt: /* @__PURE__ */ new Date(),
updatedAt: /* @__PURE__ */ new Date()
});
}
const session = await ctx.context.internalAdapter.createSession(user.id);
if (!session) throw APIError.fromStatus("INTERNAL_SERVER_ERROR", {
message: "Internal Server Error",
status: 500
});
await setSessionCookie(ctx, {
session,
user
});
return ctx.json({
token: session.token,
success: true,
user: {
id: user.id,
walletAddress,
chainId
}
});
} catch (error) {
if (isAPIError(error)) throw error;
throw APIError.fromStatus("UNAUTHORIZED", {
message: "Something went wrong. Please try again later.",
error: error instanceof Error ? error.message : "Unknown error",
status: 401
});
}
})
},
options
};
};
//#endregion
export { siwe };