UNPKG

better-near-auth

Version:

Sign in with NEAR (SIWN) plugin for Better Auth

421 lines (326 loc) 13.6 kB
<!-- markdownlint-disable MD014 --> <!-- markdownlint-disable MD033 --> <!-- markdownlint-disable MD041 --> <!-- markdownlint-disable MD029 --> <div align="center"> <h1 style="font-size: 2.5rem; font-weight: bold;">better-near-auth</h1> <p> <strong>Sign in with NEAR + gasless relay plugin for Better Auth</strong> </p> </div> This [Better Auth](https://better-auth.com) plugin enables secure authentication via NEAR wallets following [NEP-413](https://github.com/near/NEPs/blob/master/neps/nep-0413.md) and adds a built-in [NEP-366](https://github.com/near/NEPs/blob/master/neps/nep-0366.md) delegate action relayer so authenticated users can call on-chain contracts gaslessly. It uses [near-kit](https://github.com/elliotBraem/near-kit) for RPC queries and transaction broadcasting, and [@hot-labs/near-connect](https://github.com/azbang/near-connect) for wallet connection. ## Features - **SIWN authentication** — wallet-based sign-in with automatic single-step/two-step flow detection - **Gasless relay** — server relays signed delegate actions on-chain, paying gas from a relayer account - **Ephemeral relayer keypair** — auto-generated ED25519 keypair on first startup, private key encrypted with AES-256-GCM in the database, persists across restarts - **Profile integration** — near-kit profile lookup primary, NEAR Social fallback ## Installation 1. Install the package ```bash npm install better-near-auth ``` 2. Add the SIWN plugin to your auth configuration: ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { siwn } from "better-near-auth"; export const auth = betterAuth({ database: drizzleAdapter(db, { // db configuration }), plugins: [ siwn({ recipient: "myapp.com", // Optional: enable gasless relay relayer: { whitelistedContracts: ["myapp.near"], }, }), ], }); ``` 3. Generate the schema to add the necessary fields and tables to the database. ```bash npx @better-auth/cli generate ``` 4. Add the Client Plugin ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client"; import { siwnClient } from "better-near-auth/client"; export const authClient = createAuthClient({ plugins: [ siwnClient({ recipient: "myapp.com", networkId: "mainnet", }) ], }); ``` ## Usage ### Sign In with NEAR The `signIn.near()` method automatically detects wallet capabilities and uses the best available flow: ```tsx title="LoginButton.tsx" import { authClient } from "./auth-client"; import { useState } from "react"; export function LoginButton() { const { data: session } = authClient.useSession(); const [isSigningIn, setIsSigningIn] = useState(false); if (session) { return ( <div> <p>Welcome, {session.user.name}!</p> <button onClick={() => authClient.near.disconnect()}>Sign out</button> </div> ); } const handleSignIn = async () => { setIsSigningIn(true); await authClient.signIn.near({ onSuccess: () => { setIsSigningIn(false); }, onError: (error) => { setIsSigningIn(false); console.error("Sign in failed:", error.message); }, }); }; return ( <button onClick={handleSignIn} disabled={isSigningIn}> {isSigningIn ? "Signing in..." : "Sign in with NEAR"} </button> ); } ``` **Supported wallets:** HOT Wallet, Meteor Wallet, Intear Wallet, MyNearWallet, and more. ### Gasless Relay Once the relayer is configured on the server, authenticated users can call on-chain contracts without paying gas: ```ts // 1. Build a signed delegate action using the wallet's FAK import { Gas } from "near-kit"; const signedAction = await authClient.near.buildSignedDelegateAction( "myapp.near", (builder, receiverId) => builder.functionCall(receiverId, "some_method", { key: "value" }, { gas: Gas.Tgas(30), attachedDeposit: BigInt(0), }) ); // 2. Relay it — the server pays gas const result = await authClient.near.relayTransaction({ payload: signedAction, }); console.log("Tx hash:", result.txHash); // 3. Check status const status = await authClient.near.getRelayStatus(result.txHash); ``` ### Profile Access ```ts const myProfile = await authClient.near.getProfile(); const aliceProfile = await authClient.near.getProfile("alice.near"); ``` ### Wallet Management ```ts const accountId = authClient.near.getAccountId(); await authClient.near.disconnect(); ``` ## Configuration Options ### Server Options | Option | Type | Default | Description | |---|---|---|---| | `recipient` | `string` | — | NEP-413 recipient identifier (required) | | `requireFullAccessKey` | `boolean` | `false` | Require full access keys | | `getNonce` | `() => Promise<Uint8Array>` | — | Custom nonce generation | | `getProfile` | `(accountId: string) => Promise<Profile \| null>` | — | Custom profile lookup | | `validateLimitedAccessKey` | `(args) => Promise<boolean>` | — | Validate FAK when `requireFullAccessKey` is false | | `apiKey` | `string` | `process.env.FASTNEAR_API_KEY` | API key for RPC | | `rpcUrl` | `string` | — | Custom RPC URL (e.g., sandbox, private node) | | `relayer` | `RelayerConfig` | — | Relayer configuration (see below) | #### Relayer Configuration | Option | Type | Default | Description | |---|---|---|---| | `accountId` | `string` | — | Named relayer account (explicit mode) | | `privateKey` | `string` | — | Base64 private key (explicit mode) | | `whitelistedContracts` | `string[]` | — | Restrict relay to these contracts | | `maxGasPerTransaction` | `string` | — | Max gas per relayed tx | | `maxDepositPerTransaction` | `string` | — | Max deposit per relayed tx | When `accountId` and `privateKey` are omitted, the relayer starts in **ephemeral mode**: an ED25519 keypair is generated on first startup, the implicit account ID is derived from the public key, and the private key is encrypted with AES-256-GCM (using `BETTER_AUTH_SECRET` as KEK via HKDF-SHA256) and stored in the database. The same keypair is recovered on restart. ### Client Options | Option | Type | Default | Description | |---|---|---|---| | `recipient` | `string` | — | NEP-413 recipient (must match server) | | `networkId` | `"mainnet" \| "testnet"` | `"mainnet"` | NEAR network | ## Schema ### nearAccount | Field | Type | Description | |---|---|---| | id | string | Primary key | | userId | string | → user.id | | accountId | string | NEAR account ID | | network | string | mainnet/testnet | | publicKey | string | Associated public key | | isPrimary | boolean | User's primary account | | createdAt | date | | ### relayedTransaction | Field | Type | Description | |---|---|---| | userId | string | → user.id | | txHash | string | On-chain tx hash | | senderId | string | Delegate action sender | | receiverId | string | Contract called | | status | string | pending/completed/failed | | gasUsed | string | Gas consumed | | createdAt | date | | ### relayerKey | Field | Type | Description | |---|---|---| | id | string | Singleton per network | | accountId | string | Implicit NEAR account ID | | encryptedPrivateKey | string | AES-256-GCM encrypted, base64 | | iv | string | Initialization vector, base64 | | publicKey | string | ed25519:base64 format | | network | string | mainnet/testnet | | createdAt | date | | | lastUsedAt | date | Updated on each relay | ## API Reference ### Client Actions — `authClient.near` **SIWN** - `nonce(params)` — Request a nonce from the server - `verify(params)` — Verify an auth token with the server - `getProfile(accountId?)` — Get user profile (near-kit profile lookup → NEAR Social fallback) - `getAccountId()` — Currently connected account ID - `getState()` — Current wallet state - `disconnect()` — Disconnect wallet and clear cached data - `link(callbacks?)` — Link a NEAR account to the current session - `unlink(params)` — Unlink a NEAR account - `listAccounts()` — List all linked NEAR accounts **Relay** - `buildSignedDelegateAction(receiverId, buildActions)` — Build + sign a delegate action via wallet FAK - `relayTransaction({ payload })` — Submit a signed delegate action to the relayer - `getRelayStatus(txHash)` — Check relayed transaction status - `getRelayerInfo()` — Get relayer account info, mode, and balance - `relayHistory()` — List relayed transactions for current user ### `authClient.signIn` - `near(callbacks?)` — Connect wallet, sign message, and authenticate (single popup) ### Callback Interface ```typescript interface AuthCallbacks { onSuccess?: () => void; onError?: (error: Error & { status?: number; code?: string }) => void; } ``` ### Error Codes | Code | Description | |---|---| | `UNAUTHORIZED_NONCE_REPLAY` | Nonce already used (replay attack detected) | | `UNAUTHORIZED` | Generic auth failure (invalid signature, account mismatch, etc.) | ### Server Endpoints | Method | Path | Description | |---|---|---| | POST | `/near/nonce` | Generate nonce for signing | | POST | `/near/verify` | Verify NEP-413 signature, create session | | POST | `/near/profile` | Get NEAR profile | | POST | `/near/link-account` | Link NEAR account to session | | POST | `/near/unlink-account` | Unlink NEAR account | | GET | `/near/list-accounts` | List linked NEAR accounts | | POST | `/near/relay` | Relay a signed delegate action on-chain | | GET | `/near/relay-status/:txHash` | Check relayed transaction status | | GET | `/near/relayer-info` | Get relayer accountId, mode, balance | | GET | `/near/relay-history` | List relayed transactions for current user | | POST | `/near/view` | Server-side read-only contract call (authenticated) | ## Advanced Configuration ```ts title="advanced-auth.ts" import { betterAuth } from "better-auth"; import { siwn } from "better-near-auth"; import { generateNonce } from "near-kit"; const usedNonces = new Set<string>(); export const auth = betterAuth({ plugins: [ siwn({ recipient: "myapp.com", requireFullAccessKey: false, getNonce: async () => generateNonce(), getProfile: async (accountId) => { try { const res = await fetch(`https://api.myapp.com/profiles/${accountId}`); if (res.ok) { const p = await res.json(); return { name: p.displayName, description: p.bio, image: { url: p.avatar } }; } } catch {} return null; }, validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => { const allowed = ["myapp.near", "social.near"]; return recipient ? allowed.includes(recipient) : true; }, apiKey: process.env.FASTNEAR_API_KEY, relayer: { accountId: "relayer.myapp.near", privateKey: process.env.RELAYER_PRIVATE_KEY, whitelistedContracts: ["myapp.near"], maxGasPerTransaction: "300000000000000", maxDepositPerTransaction: "0", }, }), ], }); ``` ## Network Support The plugin detects the network from the account ID: - Accounts ending with `.testnet` → testnet - All other accounts → mainnet ## Security ### NEP-413 Compliance - Proper nonce handling prevents replay attacks - Message format and recipient validation - 15-minute server-side nonce expiration with DB replay detection ### Relayer Key Security - Ephemeral private key encrypted at rest with AES-256-GCM - KEK derived from `BETTER_AUTH_SECRET` via HKDF-SHA256 - Private key held only in process memory — never in env vars or config files - Trust model matches Better Auth session tokens: DB access + secret = full access ### Access Key Support - Full access keys and function-call access keys (FAK) - FAK scoped to recipient contract for delegate actions - Configurable validation for limited access keys ## Troubleshooting | Issue | Solution | |---|---| | "Invalid or expired nonce" | Server nonces expire after 15 min; check clock sync | | "Account ID mismatch" | Verify signed message account ID matches wallet | | "Network ID mismatch" | Ensure networkId matches the account's network | | Relay fails with "insufficient balance" | Fund the relayer account with NEAR | | Relay fails with "contract not whitelisted" | Add `receiverId` to `whitelistedContracts` | ## Examples ### Browser to Server Example A full-stack example showing NEAR authentication + gasless relay. - **Location**: `examples/browser-2-server/` - **Live Demo**: [better-near-auth.near.page](https://better-near-auth.near.page) - **Tech Stack**: Hono, Drizzle ORM, React, TanStack Router ```bash # From repo root pnpm install cd examples/browser-2-server pnpm dev ``` ## Development Interested in contributing? See [CONTRIBUTING.md](./CONTRIBUTING.md). **Quick start:** ```bash pnpm install pnpm build pnpm typecheck pnpm test ``` **Build output:** - `dist/index.js` — Server plugin (ESM) - `dist/client.js` — Client plugin (ESM) - `dist/*.d.ts` — TypeScript declarations ## Links - [Better Auth Documentation](https://better-auth.com) - [NEAR Protocol](https://near.org) - [NEP-413 Specification](https://github.com/near/NEPs/blob/master/neps/nep-0413.md) - [NEP-366 Delegate Actions](https://github.com/near/NEPs/blob/master/neps/nep-0366.md) - [near-kit](https://github.com/elliotBraem/near-kit) - [@hot-labs/near-connect](https://github.com/azbang/near-connect) - [Example Implementation](https://better-near-auth.near.page) - [Contributing Guide](./CONTRIBUTING.md)