better-near-auth
Version:
Sign in with NEAR (SIWN) plugin for Better Auth
421 lines (326 loc) • 13.6 kB
Markdown
<!-- 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)