UNPKG

better-near-auth

Version:

Sign in with NEAR (SIWN) plugin for Better Auth

400 lines (312 loc) 11.2 kB
--- name: siwn description: > Set up the SIWN server plugin for Better Auth, configure NEP-413 authentication with recipient and API key, handle nonce generation, signature verification, account linking and unlinking, and NEAR profile lookup. Load when adding NEAR wallet sign-in to a Better Auth server, configuring siwn() plugin options, or debugging NEP-413 verify or nonce issues. metadata: type: core library: better-near-auth library_version: "1.7.3" sources: - "elliotBraem/better-near-auth:src/index.ts" - "elliotBraem/better-near-auth:src/profile.ts" - "elliotBraem/better-near-auth:src/types.ts" - "elliotBraem/better-near-auth:src/schema.ts" - "elliotBraem/better-near-auth:README.md" - "elliotBraem/better-near-auth:LLM.txt" --- # Better-Near-Auth — SIWN Authentication Better Auth plugin for NEAR wallet authentication following NEP-413. Provides server-side nonce generation, signature verification, account linking, and profile lookup. ## Setup ```typescript import { betterAuth } from "better-auth"; import { siwn } from "better-near-auth"; export const auth = betterAuth({ database: drizzleAdapter(db, { // your DB adapter config }), plugins: [ siwn({ recipient: "myapp.com", apiKey: process.env.FASTNEAR_API_KEY, }), ], }); ``` After adding the plugin, generate the database schema: ```bash npx @better-auth/cli generate ``` This creates three tables: `nearAccount`, `relayedTransaction`, and `relayerKey`. ## Core Patterns ### Configure with custom RPC URL For sandbox or private nodes, override the default RPC endpoint: ```typescript siwn({ recipient: "myapp.com", rpcUrl: "http://localhost:3030", apiKey: process.env.FASTNEAR_API_KEY, }); ``` ### Custom profile lookup Override the default FastNear KV → NEAR Social fallback chain: ```typescript siwn({ recipient: "myapp.com", getProfile: async (accountId) => { 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 } }; } return null; }, }); ``` ### Validate function-call access keys By default the plugin validates that the signing key is either a full-access key or a function-call key scoped to the recipient. Override with `validateLimitedAccessKey`: ```typescript siwn({ recipient: "myapp.com", validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => { const allowed = ["myapp.near", "social.near"]; return recipient ? allowed.includes(recipient) : true; }, }); ``` ### Link and unlink NEAR accounts After authentication, link additional NEAR accounts to the same session: ```typescript // Client: link another NEAR account await authClient.near.link({ onSuccess: () => console.log("linked"), onError: (err) => console.error(err), }); // Client: unlink an account await authClient.near.unlink({ accountId: "alice.near" }); ``` You cannot unlink the last authentication method — link another account first. ### Sub-account creation Create named sub-accounts (e.g. `myapp.parent.near`). See the **[subaccount skill](../subaccount/SKILL.md)** for the full reference. Basic setup: ```typescript siwn({ recipient: "myapp.com", relayer: { accountId: "relayer.myapp.near", privateKey: process.env.RELAYER_PRIVATE_KEY, }, subAccount: { parentAccount: "myapp.near", }, }); ``` If the parent account differs from the relayer account, provide the parent key via `secrets`: ```typescript siwn({ recipient: "myapp.com", relayer: { accountId: "relayer.myapp.near", privateKey: process.env.RELAYER_PRIVATE_KEY, }, secrets: { parentKey: process.env.PARENT_KEY, // for signing as a different parent }, subAccount: { parentAccount: "user.parent.near", minDeposit: "0.5 NEAR", }, }); ``` Client-side flow: ```typescript // 1. Check availability first const { data } = await authClient.near.checkSubAccountAvailability({ subAccountName: "myapp", }); if (!data.available) { console.log("Not available:", data.reason); return; } // 2. Create the sub-account with the user's public key const result = await authClient.near.createSubAccount({ subAccountName: "myapp", publicKey: "ed25519:...", // user's public key }); console.log(result.data.accountId); // myapp.parent.near ``` To use parent ownership, contract deployment, init calls, transaction hooks, or lifecycle callbacks with automatic rollback, see the **[subaccount skill](../subaccount/SKILL.md)**. ## Server Endpoints | Method | Path | Description | | ------ | ---- | ----------- | | POST | `/near/nonce` | Generate hex-encoded nonce for signing | | POST | `/near/verify` | Verify NEP-413 signature, create session | | POST | `/near/profile` | Get NEAR profile (requires session) | | 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/set-primary-account` | Set primary linked NEAR account | | POST | `/near/create-sub-account` | Create a sub-account under a parent account | | POST | `/near/check-sub-account-availability` | Check if a sub-account name is available | ## Plugin Options | Option | Type | Default | Description | | ------ | ---- | ------- | ----------- | | `recipient` | `string` | — | NEP-413 recipient identifier (required) | | `requireFullAccessKey` | `boolean` | `false` | Require full access keys | | `getNonce` | `() => Promise<Uint8Array>` | `generateNonce()` | Custom nonce generation | | `getProfile` | `(accountId) => Promise<Profile \| null>` | FastNear KV → NEAR Social | Custom profile lookup | | `validateLimitedAccessKey` | `(args) => Promise<boolean>` | Default FAK validation | Validate limited access keys | | `apiKey` | `string` | `process.env.FASTNEAR_API_KEY` | API key for RPC | | `rpcUrl` | `string` | — | Custom RPC URL | | `relayer` | `RelayerConfig` | — | See relay skill | | `recipients` | `DualNetworkConfig<string>` | — | Per-network recipient (overrides `recipient` for mainnet/testnet) | | `subAccount` | `SubAccountConfig \| DualNetworkConfig<SubAccountConfig>` | — | Sub-account configuration for delegated account creation | ## Email Handling - `.near` accounts: `efiz.near` → `efiz@near.email` (automatic) - Non-`.near` accounts (e.g. implicit hex accounts): no email set; users add email via other Better Auth plugins ## Common Mistakes ### CRITICAL Recipient mismatch between server and client Wrong: ```typescript // Server siwn({ recipient: "myapp.near" }); // Client siwnClient({ recipient: "myapp.com" }); ``` Correct: ```typescript // Server siwn({ recipient: "myapp.com" }); // Client siwnClient({ recipient: "myapp.com" }); ``` The client signs a message with the recipient embedded. The server verifies against its own recipient. A mismatch causes verification to fail with no clear error. Source: src/index.ts:225, src/client.ts:17 See also: client/SKILL.md — client recipient must match server recipient ### HIGH Sending raw nonce bytes instead of hex-encoded string Wrong: ```typescript const nonce = new Uint8Array(32); await authClient.near.verify({ signedMessage, message, recipient, nonce: nonce, // raw bytes accountId, }); ``` Correct: ```typescript const nonceBytes = generateNonce(); const nonceHex = hex.encode(nonceBytes); // ...sign with nonceBytes via wallet... await authClient.near.verify({ signedMessage, message, recipient, nonce: nonceHex, // hex-encoded string accountId, }); ``` The verify endpoint hex-decodes the nonce string. Sending raw bytes or a non-hex string causes decode failure or nonce replay detection failure. Source: src/types.ts:53-58, src/client.ts:106-109 ### HIGH Forgetting to generate DB schema after adding plugin Wrong: ```typescript // Add siwn() to plugins, then start the server immediately export const auth = betterAuth({ plugins: [siwn({ recipient: "myapp.com" })], }); ``` Correct: ```typescript // Add siwn() to plugins, THEN generate schema before starting export const auth = betterAuth({ plugins: [siwn({ recipient: "myapp.com" })], }); // Run: npx @better-auth/cli generate ``` The plugin adds nearAccount, relayedTransaction, and relayerKey tables. Without running the CLI, the database will be missing these tables and all endpoints will fail at runtime with adapter errors. Source: README.md:56-60 ### MEDIUM Network mismatch from account ID suffix Wrong: ```typescript await authClient.near.nonce({ accountId: "alice.near", networkId: "testnet", // .near is mainnet }); ``` Correct: ```typescript await authClient.near.nonce({ accountId: "alice.near", networkId: "mainnet", // matches .near suffix }); ``` Network is auto-detected from the accountId: `.testnet` → testnet, otherwise → mainnet. The nonce endpoint validates that networkId matches the account's detected network and rejects mismatches. Source: src/profile.ts:6-8, src/index.ts:546-552 See also: client/SKILL.md — client siwnClient networkId should match the account's network ### HIGH Not configuring parentAccount for sub-account creation with ephemeral relayer Wrong: ```typescript siwn({ recipient: "myapp.com", relayer: {}, // ephemeral mode — implicit hex account subAccount: {}, // no parentAccount set }); // Server throws: "Sub-account creation requires a named parent account" ``` Correct: ```typescript siwn({ recipient: "myapp.com", relayer: { accountId: "relayer.myapp.near", privateKey: process.env.RELAYER_PRIVATE_KEY, }, subAccount: { parentAccount: "myapp.near", }, }); ``` Ephemeral mode generates an implicit hex account (e.g. `7a3c4b5c...`) which cannot own sub-accounts — NEAR only allows named accounts to create sub-accounts. Set `subAccount.parentAccount` to a named account, or use an explicit relayer with a named account and omit `parentAccount` (defaults to relayer accountId). Source: src/index.ts:1395-1405, src/index.ts:290-304 ### MEDIUM Missing secrets.parentKey when parent account differs from relayer Wrong: ```typescript siwn({ recipient: "myapp.com", relayer: { accountId: "relayer.myapp.near", privateKey: process.env.RELAYER_PRIVATE_KEY, }, subAccount: { parentAccount: "user.parent.near", // different from relayer // no secrets.parentKey }, }); // Server throws: "Sub-account parent differs from relayer account" ``` Correct: ```typescript siwn({ recipient: "myapp.com", relayer: { accountId: "relayer.myapp.near", privateKey: process.env.RELAYER_PRIVATE_KEY, }, secrets: { parentKey: process.env.PARENT_KEY, // used to sign as parent }, subAccount: { parentAccount: "user.parent.near", minDeposit: "0.5 NEAR", }, }); ``` The creation transaction must be signed by the parent account. If `parentAccount` differs from the relayer account, provide `secrets.parentKey` so the server can sign as the parent. Source: src/index.ts:1403-1415, src/index.ts:1420-1423 See also: [subaccount skill](../subaccount/SKILL.md)