UNPKG

better-auth

Version:

The most comprehensive authentication framework for TypeScript.

276 lines (275 loc) • 10 kB
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 };