better-near-auth
Version:
Sign in with NEAR (SIWN) plugin for Better Auth
377 lines • 15.5 kB
JavaScript
import { Near, fromNearConnect, generateNonce, TransactionBuilder } from "near-kit";
import { NearConnector } from "@hot-labs/near-connect";
import { hex } from "@scure/base";
import { atom } from "nanostores";
import {} from "./types.js";
export const siwnClient = (config) => {
const nearState = atom(null);
const walletConnected = atom(false);
const network = config.networkId || "mainnet";
let connector = null;
let near = null;
let clientInitialized = false;
const handleAccountConnection = async (accountId, publicKey) => {
if (!accountId)
return;
nearState.set({
accountId,
publicKey: publicKey || null,
networkId: network,
});
walletConnected.set(true);
};
const initClient = ($fetch) => {
if (clientInitialized)
return true;
if (typeof globalThis.window === "undefined")
return false;
connector = new NearConnector({ network });
near = new Near({
network,
wallet: fromNearConnect(connector),
});
connector.on("wallet:signIn", async (data) => {
const accountId = data.accounts?.[0]?.accountId;
const publicKey = data.accounts?.[0]?.publicKey;
if (accountId) {
await handleAccountConnection(accountId, publicKey);
}
});
connector.on("wallet:signOut", () => {
walletConnected.set(false);
const state = nearState.get();
if (state) {
nearState.set({ accountId: state.accountId, publicKey: null, networkId: state.networkId });
}
});
connector.getConnectedWallet().then(({ accounts }) => {
const account = accounts?.[0];
if (account?.accountId && !nearState.get()) {
nearState.set({
accountId: account.accountId,
publicKey: account.publicKey ?? null,
networkId: network,
});
}
if (account?.accountId) {
walletConnected.set(true);
}
}).catch(() => { });
if ($fetch) {
restoreFromSession($fetch);
}
clientInitialized = true;
return true;
};
let sessionRestored = false;
const restoreFromSession = async ($fetch) => {
if (sessionRestored)
return;
const state = nearState.get();
if (state?.accountId) {
sessionRestored = true;
return;
}
try {
const res = await $fetch("/near/list-accounts", { method: "GET" });
const accounts = res.data?.accounts;
if (accounts?.length) {
const primary = accounts.find((a) => a.isPrimary) || accounts[0];
if (primary) {
nearState.set({
accountId: primary.accountId,
publicKey: primary.publicKey ?? null,
networkId: primary.network,
});
}
}
}
catch { }
sessionRestored = true;
};
const requireConnector = () => {
if (!connector)
throw new Error("Wallet not initialized — this operation requires a browser environment");
return connector;
};
const requireNear = () => {
if (!near)
throw new Error("Wallet not initialized — this operation requires a browser environment");
return near;
};
const ensureWalletConnected = async () => {
const conn = requireConnector();
if (walletConnected.get()) {
try {
const { accounts } = await conn.getConnectedWallet();
if (accounts?.length)
return true;
}
catch { }
}
return new Promise((resolve) => {
const signInHandler = (data) => {
const accountId = data.accounts?.[0]?.accountId;
const publicKey = data.accounts?.[0]?.publicKey;
if (accountId) {
handleAccountConnection(accountId, publicKey);
resolve(true);
}
};
conn.on("wallet:signIn", signInHandler);
conn.connect().catch(() => { }).finally(() => {
conn.off("wallet:signIn", signInHandler);
if (!walletConnected.get()) {
resolve(false);
}
});
});
};
const signWithWallet = async () => {
const conn = requireConnector();
const nearClient = requireNear();
const nonceBytes = generateNonce();
const nonceHex = hex.encode(nonceBytes);
const message = `Sign in to ${config.recipient}`;
let connectedWallet = null;
try {
connectedWallet = await conn.getConnectedWallet();
}
catch { }
if (connectedWallet?.accounts?.length) {
const signedMessage = await nearClient.signMessage({
message,
recipient: config.recipient,
nonce: nonceBytes,
});
if (!signedMessage?.accountId) {
throw new Error("Wallet sign-in was cancelled or failed");
}
return {
signedMessage,
accountId: signedMessage.accountId,
publicKey: signedMessage.publicKey,
nonceHex,
};
}
const result = { value: null };
const handler = (data) => {
const account = data.accounts?.[0];
if (account?.signedMessage) {
result.value = {
signedMessage: account.signedMessage,
accountId: account.accountId,
publicKey: account.signedMessage.publicKey,
};
}
};
conn.on("wallet:signInAndSignMessage", handler);
try {
await conn.connect({
signMessageParams: {
message,
recipient: config.recipient,
nonce: nonceBytes,
},
});
}
finally {
conn.off("wallet:signInAndSignMessage", handler);
}
if (!result.value) {
throw new Error("Wallet sign-in was cancelled or failed");
}
return {
signedMessage: result.value.signedMessage,
accountId: result.value.accountId,
publicKey: result.value.publicKey,
nonceHex,
};
};
const buildSignedDelegateActionInternal = async (receiverId, buildActions) => {
const state = nearState.get();
if (!state?.accountId) {
throw new Error("No NEAR account found — please sign in with your NEAR wallet");
}
if (!walletConnected.get()) {
const reconnected = await ensureWalletConnected();
if (!reconnected) {
throw new Error("Wallet connection required — please approve the connection to sign");
}
}
const nearClient = requireNear();
const builder = buildActions(nearClient.transaction(state.accountId), receiverId);
const { payload } = await builder.delegate();
return payload;
};
return {
id: "siwn",
$InferServerPlugin: {},
getAtoms: (_$fetch) => ({
nearState,
walletConnected,
}),
getActions: ($fetch, _$store, _options) => {
initClient($fetch);
return {
near: {
nonce: async (params, fetchOptions) => {
return await $fetch("/near/nonce", {
method: "POST",
body: params,
...fetchOptions
});
},
verify: async (params, fetchOptions) => {
return await $fetch("/near/verify", {
method: "POST",
body: params,
...fetchOptions
});
},
getProfile: async (accountId, fetchOptions) => {
return await $fetch("/near/profile", {
method: "POST",
body: { accountId },
...fetchOptions
});
},
view: async (params, fetchOptions) => {
return await $fetch("/near/view", {
method: "POST",
body: params,
...fetchOptions
});
},
getAccountId: () => {
const state = nearState.get();
return state?.accountId || null;
},
getState: () => nearState.get(),
isWalletConnected: () => walletConnected.get(),
ensureConnected: async () => {
if (!clientInitialized) {
if (!initClient())
return false;
}
if (walletConnected.get()) {
try {
const { accounts } = await requireConnector().getConnectedWallet();
if (accounts?.length)
return true;
}
catch (err) {
console.error("[siwn] restoreFromSession failed:", err instanceof Error ? err.message : err);
}
}
return ensureWalletConnected();
},
disconnect: async () => {
if (connector)
await connector.disconnect();
walletConnected.set(false);
nearState.set(null);
},
link: async (callbacks) => {
try {
const { signedMessage, accountId, nonceHex } = await signWithWallet();
const message = `Sign in to ${config.recipient}`;
await handleAccountConnection(accountId, signedMessage.publicKey);
const linkResponse = await $fetch("/near/link-account", {
method: "POST",
body: {
signedMessage,
message,
recipient: config.recipient,
nonce: nonceHex,
accountId,
}
});
if (linkResponse.error) {
throw new Error(linkResponse.error.message || "Failed to link NEAR account");
}
if (!linkResponse?.data?.success) {
throw new Error("Account linking failed");
}
callbacks?.onSuccess?.();
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
callbacks?.onError?.(err);
}
},
unlink: async (params, fetchOptions) => {
return await $fetch("/near/unlink-account", {
method: "POST",
body: params,
...fetchOptions
});
},
listAccounts: async () => {
return await $fetch("/near/list-accounts", { method: "GET" });
},
buildSignedDelegateAction: async (receiverId, buildActions) => {
return buildSignedDelegateActionInternal(receiverId, buildActions);
},
relayTransaction: async (params) => {
return await $fetch("/near/relay", {
method: "POST",
body: params,
});
},
getRelayStatus: async (txHash) => {
return await $fetch(`/near/relay-status/${txHash}`, {
method: "GET",
});
},
getRelayerInfo: async () => {
return await $fetch("/near/relayer-info", {
method: "GET",
});
},
relayHistory: async () => {
return await $fetch("/near/relay-history", {
method: "GET",
});
},
get client() {
if (!near)
throw new Error("Wallet not initialized — this operation requires a browser environment");
return near;
},
},
signIn: {
near: async (callbacks) => {
try {
const { signedMessage, accountId, nonceHex } = await signWithWallet();
const message = `Sign in to ${config.recipient}`;
await handleAccountConnection(accountId, signedMessage.publicKey);
const verifyResponse = await $fetch("/near/verify", {
method: "POST",
body: {
signedMessage,
message,
recipient: config.recipient,
nonce: nonceHex,
accountId,
}
});
if (verifyResponse.error) {
throw new Error(verifyResponse.error.message || "Failed to verify signature");
}
if (!verifyResponse?.data?.success) {
throw new Error("Authentication verification failed");
}
callbacks?.onSuccess?.();
}
catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
callbacks?.onError?.(err);
}
}
}
};
}
};
};
//# sourceMappingURL=client.js.map