better-near-auth 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -55,20 +55,41 @@ npm install better-near-auth
55
55
  import { siwnClient } from "better-near-auth/client";
56
56
 
57
57
  export const authClient = createAuthClient({
58
- plugins: [siwnClient()],
58
+ plugins: [
59
+ siwnClient({
60
+ domain: "myapp.com", // this doesn't actually do anything yet... taking suggestions
61
+ networkId: "mainnet", // optional, default is "mainnet"
62
+ })
63
+ ],
59
64
  });
60
65
  ```
61
66
 
62
-
63
67
  ## Usage
64
68
 
65
- ### One-Line Authentication
69
+ ### Two-Step Authentication Flow
70
+
71
+ The plugin uses a secure two-step authentication process:
72
+
73
+ 1. **Step 1**: Connect wallet and cache nonce
74
+ 2. **Step 2**: Sign message and authenticate
66
75
 
67
- The simplest way to authenticate with NEAR:
76
+ ```ts title="two-step-auth.ts"
77
+ // Step 1: Connect wallet and get nonce
78
+ await authClient.requestSignIn.near(
79
+ { recipient: "myapp.com" },
80
+ {
81
+ onSuccess: () => {
82
+ console.log("Wallet connected, nonce cached!");
83
+ },
84
+ onError: (error) => {
85
+ console.error("Wallet connection failed:", error.message);
86
+ }
87
+ }
88
+ );
68
89
 
69
- ```ts title="sign-in-near.ts"
70
- const response = await authClient.signIn.near(
71
- { recipient: "myapp.com", signer: window.near },
90
+ // Step 2: Sign message and authenticate
91
+ await authClient.signIn.near(
92
+ { recipient: "myapp.com" },
72
93
  {
73
94
  onSuccess: () => {
74
95
  console.log("Successfully signed in!");
@@ -78,8 +99,91 @@ const response = await authClient.signIn.near(
78
99
  }
79
100
  }
80
101
  );
102
+ ```
103
+
104
+ ### Complete React Component Example
105
+
106
+ ```tsx title="LoginButton.tsx"
107
+ import { authClient } from "./auth-client";
108
+ import { useState } from "react";
109
+
110
+ export function LoginButton() {
111
+ const { data: session } = authClient.useSession();
112
+ const [isConnectingWallet, setIsConnectingWallet] = useState(false);
113
+ const [isSigningIn, setIsSigningIn] = useState(false);
114
+
115
+ // Get account ID from embedded fastintear client
116
+ const accountId = authClient.near.getAccountId();
117
+
118
+ if (session) {
119
+ return (
120
+ <div>
121
+ <p>Welcome, {session.user.name}!</p>
122
+ <button onClick={() => authClient.signOut()}>Sign out</button>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ const handleWalletConnect = async () => {
128
+ setIsConnectingWallet(true);
129
+
130
+ try {
131
+ await authClient.requestSignIn.near(
132
+ { recipient: "myapp.com" },
133
+ {
134
+ onSuccess: () => {
135
+ setIsConnectingWallet(false);
136
+ console.log("Wallet connected!");
137
+ },
138
+ onError: (error) => {
139
+ setIsConnectingWallet(false);
140
+ console.error("Wallet connection failed:", error.message);
141
+ },
142
+ }
143
+ );
144
+ } catch (error) {
145
+ setIsConnectingWallet(false);
146
+ console.error("Authentication error:", error);
147
+ }
148
+ };
149
+
150
+ const handleSignIn = async () => {
151
+ setIsSigningIn(true);
152
+
153
+ try {
154
+ await authClient.signIn.near(
155
+ { recipient: "myapp.com" },
156
+ {
157
+ onSuccess: () => {
158
+ setIsSigningIn(false);
159
+ console.log("Successfully signed in!");
160
+ },
161
+ onError: (error) => {
162
+ setIsSigningIn(false);
163
+ console.error("Sign in failed:", error.message);
164
+ },
165
+ }
166
+ );
167
+ } catch (error) {
168
+ setIsSigningIn(false);
169
+ console.error("Authentication error:", error);
170
+ }
171
+ };
81
172
 
82
- console.log("Signed in as:", response.user.accountId);
173
+ return (
174
+ <div>
175
+ {!accountId ? (
176
+ <button onClick={handleWalletConnect} disabled={isConnectingWallet}>
177
+ {isConnectingWallet ? "Connecting..." : "Connect NEAR Wallet"}
178
+ </button>
179
+ ) : (
180
+ <button onClick={handleSignIn} disabled={isSigningIn}>
181
+ {isSigningIn ? "Signing in..." : `Sign in with NEAR (${accountId})`}
182
+ </button>
183
+ )}
184
+ </div>
185
+ );
186
+ }
83
187
  ```
84
188
 
85
189
  ### Profile Access
@@ -96,6 +200,20 @@ const aliceProfile = await authClient.near.getProfile("alice.near");
96
200
  console.log("Alice's profile:", aliceProfile);
97
201
  ```
98
202
 
203
+ ### Wallet Management
204
+
205
+ ```ts title="wallet-management.ts"
206
+ // Check if wallet is connected
207
+ const accountId = authClient.near.getAccountId();
208
+ console.log("Connected account:", accountId);
209
+
210
+ // Get the embedded NEAR client
211
+ const nearClient = authClient.near.getNearClient();
212
+
213
+ // Disconnect wallet and clear cached data
214
+ await authClient.near.disconnect();
215
+ ```
216
+
99
217
  ## Configuration Options
100
218
 
101
219
  ### Server Options
@@ -111,11 +229,14 @@ The SIWN plugin accepts the following configuration options:
111
229
  * **validateRecipient**: Function to validate recipients. Optional, uses exact match by default
112
230
  * **validateMessage**: Function to validate messages. Optional, no validation by default
113
231
  * **getProfile**: Function to fetch user profiles. Optional, uses NEAR Social by default
114
- * **validateFunctionCallKey**: Function to validate function call access keys when `requireFullAccessKey` is false
232
+ * **validateLimitedAccessKey**: Function to validate function call access keys when `requireFullAccessKey` is false
115
233
 
116
234
  ### Client Options
117
235
 
118
- The SIWN client plugin doesn't require any configuration options:
236
+ The SIWN client plugin accepts the following configuration options:
237
+
238
+ * **domain**: Domain identifier... idk what it should do yet. Maybe shade agent.
239
+ * **networkId**: NEAR network to use ("mainnet" or "testnet"). Default is "mainnet"
119
240
 
120
241
  ```ts title="auth-client.ts"
121
242
  import { createAuthClient } from "better-auth/client";
@@ -124,7 +245,8 @@ import { siwnClient } from "better-near-auth/client";
124
245
  export const authClient = createAuthClient({
125
246
  plugins: [
126
247
  siwnClient({
127
- // Optional client configuration can go here
248
+ domain: "myapp.com",
249
+ networkId: "testnet", // Use testnet
128
250
  }),
129
251
  ],
130
252
  });
@@ -144,95 +266,51 @@ The SIWN plugin adds a `nearAccount` table to store user NEAR account associatio
144
266
  | isPrimary | boolean | Whether this is the user's primary account|
145
267
  | createdAt | date | Creation timestamp |
146
268
 
147
- ## Complete Implementation Example
269
+ ## API Reference
148
270
 
149
- Here's a complete example showing how to implement SIWN authentication:
271
+ ### Client Actions
150
272
 
151
- ```ts title="auth.ts"
152
- import { betterAuth } from "better-auth";
153
- import { siwn } from "better-near-auth";
273
+ The client plugin provides the following actions:
154
274
 
155
- export const auth = betterAuth({
156
- database: {
157
- provider: "sqlite",
158
- url: "./db.sqlite"
159
- },
160
- plugins: [
161
- siwn({
162
- recipient: "myapp.com",
163
- anonymous: false, // Require email for users
164
- emailDomainName: "myapp.com",
165
-
166
- // Optional: Custom profile lookup
167
- getProfile: async (accountId) => {
168
- // Custom profile logic, falls back to NEAR Social
169
- return null; // Use default NEAR Social lookup
170
- },
171
- }),
172
- ],
173
- });
174
- ```
275
+ #### `authClient.near`
175
276
 
176
- ```ts title="auth-client.ts"
177
- import { createAuthClient } from "better-auth/client";
178
- import { siwnClient } from "better-near-auth/client";
277
+ - `nonce(params)` - Request a nonce from the server
278
+ - `verify(params)` - Verify an auth token with the server
279
+ - `getProfile(accountId?)` - Get user profile from NEAR Social
280
+ - `getNearClient()` - Get the embedded fastintear client
281
+ - `getAccountId()` - Get the currently connected account ID
282
+ - `disconnect()` - Disconnect wallet and clear cached data
179
283
 
180
- export const authClient = createAuthClient({
181
- baseURL: "http://localhost:3000",
182
- plugins: [siwnClient()],
183
- });
184
- ```
284
+ #### `authClient.requestSignIn`
185
285
 
186
- ```tsx title="LoginButton.tsx"
187
- import { authClient } from "./auth-client";
188
- import { useState } from "react";
286
+ - `near(params, callbacks?)` - Connect wallet and cache nonce (Step 1)
189
287
 
190
- export function LoginButton() {
191
- const { data: session } = authClient.useSession();
192
- const [isSigningIn, setIsSigningIn] = useState(false);
288
+ #### `authClient.signIn`
193
289
 
194
- if (session) {
195
- return (
196
- <div>
197
- <p>Welcome, {session.user.name}!</p>
198
- <button onClick={() => authClient.signOut()}>Sign out</button>
199
- </div>
200
- );
201
- }
290
+ - `near(params, callbacks?)` - Sign message and authenticate (Step 2)
202
291
 
203
- const handleSignIn = async () => {
204
- setIsSigningIn(true);
205
-
206
- try {
207
- await authClient.signIn.near(
208
- { recipient: "myapp.com", signer: window.near },
209
- {
210
- onSuccess: () => {
211
- console.log("Successfully signed in!");
212
- },
213
- onError: (error) => {
214
- console.error("Sign in failed:", error.message);
215
- }
216
- }
217
- );
218
- } catch (error) {
219
- console.error("Authentication error:", error);
220
- } finally {
221
- setIsSigningIn(false);
222
- }
223
- };
292
+ ### Callback Interface
224
293
 
225
- return (
226
- <button onClick={handleSignIn} disabled={isSigningIn}>
227
- {isSigningIn ? "Signing in..." : "Sign in with NEAR"}
228
- </button>
229
- );
294
+ ```typescript
295
+ interface AuthCallbacks {
296
+ onSuccess?: () => void;
297
+ onError?: (error: Error & { status?: number; code?: string }) => void;
230
298
  }
231
299
  ```
232
300
 
301
+ ### Error Codes
302
+
303
+ Common error codes you may encounter:
304
+
305
+ - `SIGNER_NOT_AVAILABLE` - NEAR wallet not available
306
+ - `WALLET_NOT_CONNECTED` - Wallet not connected before signing
307
+ - `NONCE_NOT_FOUND` - No valid cached nonce found
308
+ - `ACCOUNT_MISMATCH` - Cached nonce doesn't match current account
309
+ - `UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE` - Server nonce expired or invalid
310
+
233
311
  ## Advanced Configuration
234
312
 
235
- For advanced use cases, you can customize the validation functions passed to `verify` in `near-sign-verify`:
313
+ For advanced use cases, you can customize the validation functions:
236
314
 
237
315
  ```ts title="advanced-auth.ts"
238
316
  import { betterAuth } from "better-auth";
@@ -296,15 +374,78 @@ export const auth = betterAuth({
296
374
  },
297
375
 
298
376
  // Validate function call keys against allowed contracts
299
- validateFunctionCallKey: async ({ accountId, publicKey, contractId }) => {
377
+ validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => {
300
378
  const allowedContracts = ["myapp.near", "social.near"];
301
- return contractId ? allowedContracts.includes(contractId) : true;
379
+ return recipient ? allowedContracts.includes(recipient) : true;
302
380
  },
303
381
  }),
304
382
  ],
305
383
  });
306
384
  ```
307
385
 
386
+ ## Network Support
387
+
388
+ The plugin automatically detects the network from the account ID:
389
+
390
+ - Accounts ending with `.testnet` use the testnet network
391
+ - All other accounts use the mainnet network
392
+
393
+ You can configure the client to use a specific network:
394
+
395
+ ```ts title="testnet-config.ts"
396
+ export const authClient = createAuthClient({
397
+ plugins: [
398
+ siwnClient({
399
+ domain: "myapp.com",
400
+ networkId: "testnet", // Use testnet
401
+ }),
402
+ ],
403
+ });
404
+ ```
405
+
406
+ ## Security Features
407
+
408
+ ### NEP-413 Compliance
409
+ - Follows NEAR Enhancement Proposal 413 for secure message signing
410
+ - Implements proper nonce handling to prevent replay attacks
411
+ - Validates message format and recipient information
412
+
413
+ ### Nonce Management
414
+ - Unique nonce storage per account/network/publicKey combination
415
+ - 15-minute server-side expiration for nonces
416
+ - 5-minute client-side cache expiration
417
+ - Automatic cleanup after successful authentication
418
+
419
+ ### Access Key Support
420
+ - Supports both full access keys and function call access keys
421
+ - Configurable validation for limited access keys
422
+ - Contract-specific access control when using function call keys
423
+
424
+ ## Troubleshooting
425
+
426
+ ### Common Issues
427
+
428
+ 1. **"Wallet not connected"**
429
+ - You must call `requestSignIn.near()` before `signIn.near()`
430
+ - Check that the embedded fastintear client is properly initialized
431
+
432
+ 2. **"No valid nonce found"**
433
+ - Ensure `requestSignIn.near()` completed successfully before calling `signIn.near()`
434
+ - Client nonces expire after 5 minutes
435
+
436
+ 3. **"Invalid or expired nonce"**
437
+ - Server nonces expire after 15 minutes
438
+ - Ensure client and server clocks are synchronized
439
+
440
+ 4. **"Account ID mismatch"**
441
+ - Verify the signed message contains the correct account ID
442
+ - Check for wallet switching between the two authentication steps
443
+
444
+ 5. **"Network ID mismatch"**
445
+ - Ensure the networkId sent to the server matches the account's network
446
+ - Testnet accounts must use "testnet", mainnet accounts use "mainnet"
447
+
448
+
308
449
  ## Links
309
450
 
310
451
  * [Better Auth Documentation](https://better-auth.com)
@@ -312,3 +453,4 @@ export const auth = betterAuth({
312
453
  * [NEP-413 Specification](https://github.com/near/NEPs/blob/master/neps/nep-0413.md)
313
454
  * [near-sign-verify](https://github.com/elliotBraem/near-sign-verify)
314
455
  * [fastintear](https://github.com/elliotBraem/fastintear)
456
+ * [Example Implementation](https://better-near-auth.near.page)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-near-auth",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Sign in with NEAR (SIWN) plugin for Better Auth",
5
5
  "main": "index.ts",
6
6
  "module": "index.ts",
@@ -45,7 +45,8 @@
45
45
  "dependencies": {
46
46
  "@better-auth/utils": "^0.2.6",
47
47
  "@fastnear/utils": "^0.9.7",
48
- "fastintear": "^0.2.3",
48
+ "fastintear": "link:fastintear",
49
+ "nanostores": "^1.0.1",
49
50
  "near-sign-verify": "^0.4.3",
50
51
  "zod": "^4.0.17"
51
52
  },
package/src/client.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { base64ToBytes } from "@fastnear/utils";
2
- import type { BetterAuthClientPlugin, BetterFetchOption, BetterFetchResponse } from "better-auth/client";
2
+ import type { BetterAuthClientPlugin, BetterFetch, BetterFetchOption, BetterFetchResponse } from "better-auth/client";
3
+ // TODO: tree shaking, browser vs node
4
+ import * as fastintear from "fastintear";
5
+ import { atom } from "nanostores";
3
6
  import { sign, type WalletInterface } from "near-sign-verify";
4
- import type { siwn } from ".";
7
+ import { siwn } from ".";
5
8
  import { type AccountId, type NonceRequestT, type NonceResponseT, type ProfileResponseT, type VerifyRequestT, type VerifyResponseT } from "./types";
6
9
 
7
10
  export interface Signer {
8
11
  accountId(): string | null;
9
12
  signMessage: WalletInterface["signMessage"];
13
+ requestSignIn: typeof fastintear.requestSignIn
10
14
  }
11
15
 
12
16
  export interface AuthCallbacks {
@@ -15,7 +19,17 @@ export interface AuthCallbacks {
15
19
  }
16
20
 
17
21
  export interface SIWNClientConfig {
18
- domain: string;
22
+ domain: string; // TODO: this could potentially be shade agent proxy or something, doesn't really have any purpose rn
23
+ networkId?: "mainnet" | "testnet";
24
+ // TODO: should include browser vs keypair
25
+ }
26
+
27
+ export interface CachedNonceData {
28
+ nonce: string;
29
+ accountId: string;
30
+ publicKey: string;
31
+ networkId: string;
32
+ timestamp: number;
19
33
  }
20
34
 
21
35
  export interface SIWNClientActions {
@@ -23,19 +37,44 @@ export interface SIWNClientActions {
23
37
  nonce: (params: NonceRequestT) => Promise<BetterFetchResponse<NonceResponseT>>;
24
38
  verify: (params: VerifyRequestT) => Promise<BetterFetchResponse<VerifyResponseT>>;
25
39
  getProfile: (accountId?: AccountId) => Promise<BetterFetchResponse<ProfileResponseT>>;
40
+ getNearClient: () => ReturnType<typeof fastintear.createNearClient>;
41
+ getAccountId: () => string | null;
42
+ disconnect: () => Promise<void>;
43
+ };
44
+ requestSignIn: {
45
+ near: (params: { recipient: string }, callbacks?: AuthCallbacks) => Promise<void>;
26
46
  };
27
47
  signIn: {
28
- near: (params: { recipient: string, signer: Signer }, callbacks?: AuthCallbacks) => Promise<VerifyResponseT>;
48
+ near: (params: { recipient: string }, callbacks?: AuthCallbacks) => Promise<void>;
29
49
  };
30
50
  }
31
51
 
32
52
  export interface SIWNClientPlugin extends BetterAuthClientPlugin {
33
53
  id: "siwn";
34
54
  $InferServerPlugin: ReturnType<typeof siwn>;
35
- getActions: ($fetch: any) => SIWNClientActions;
55
+ getActions: ($fetch: BetterFetch) => SIWNClientActions;
36
56
  }
37
57
 
38
58
  export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
59
+ // Create embedded NEAR client
60
+ const nearClient = fastintear.createNearClient({
61
+ networkId: config.networkId || "mainnet"
62
+ });
63
+
64
+ // Create atoms for caching nonce only
65
+ const cachedNonce = atom<CachedNonceData | null>(null);
66
+
67
+ const clearNonce = () => {
68
+ cachedNonce.set(null);
69
+ };
70
+
71
+ const isNonceValid = (nonceData: CachedNonceData | null): boolean => {
72
+ if (!nonceData) return false;
73
+ const now = Date.now();
74
+ const fiveMinutes = 5 * 60 * 1000;
75
+ return (now - nonceData.timestamp) < fiveMinutes;
76
+ };
77
+
39
78
  return {
40
79
  id: "siwn",
41
80
  $InferServerPlugin: {} as ReturnType<typeof siwn>,
@@ -62,44 +101,134 @@ export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
62
101
  body: { accountId },
63
102
  ...fetchOptions
64
103
  });
65
-
66
104
  },
105
+ getNearClient: () => nearClient,
106
+ getAccountId: () => nearClient.accountId(),
107
+ disconnect: async () => {
108
+ await nearClient.signOut();
109
+ clearNonce();
110
+ },
111
+ },
112
+ requestSignIn: {
113
+ near: async (
114
+ params: { recipient: string },
115
+ callbacks?: AuthCallbacks
116
+ ): Promise<void> => {
117
+ try {
118
+ const { recipient } = params;
119
+
120
+ if (!nearClient) {
121
+ const error = new Error("NEAR client not available") as Error & { code?: string };
122
+ error.code = "SIGNER_NOT_AVAILABLE";
123
+ throw error;
124
+ }
125
+
126
+ clearNonce();
127
+
128
+ await nearClient.requestSignIn({ contractId: recipient }, {
129
+ onSuccess: async ({ accountId, publicKey, networkId }: { accountId: string, publicKey: string, networkId: string }) => {
130
+ try {
131
+ const nonceRequest: NonceRequestT = {
132
+ accountId,
133
+ publicKey,
134
+ networkId: networkId as "mainnet" | "testnet"
135
+ };
136
+
137
+ const nonceResponse: BetterFetchResponse<NonceResponseT> = await $fetch("/near/nonce", {
138
+ method: "POST",
139
+ body: nonceRequest
140
+ });
141
+
142
+ if (nonceResponse.error) {
143
+ throw new Error(nonceResponse.error.message || "Failed to get nonce");
144
+ }
145
+
146
+ const nonce = nonceResponse?.data?.nonce;
147
+ if (!nonce) {
148
+ throw new Error("No nonce received from server");
149
+ }
150
+
151
+ // Cache nonce with all wallet data
152
+ const cachedData: CachedNonceData = {
153
+ nonce,
154
+ accountId,
155
+ publicKey,
156
+ networkId,
157
+ timestamp: Date.now()
158
+ };
159
+ cachedNonce.set(cachedData);
160
+
161
+ callbacks?.onSuccess?.();
162
+ } catch (error) {
163
+ const err = error instanceof Error ? error : new Error(String(error));
164
+ clearNonce();
165
+ callbacks?.onError?.(err);
166
+ }
167
+ },
168
+ onError: (error: any) => {
169
+ const err = error instanceof Error ? error : new Error(String(error));
170
+ clearNonce();
171
+ callbacks?.onError?.(err);
172
+ }
173
+ });
174
+ } catch (error) {
175
+ const err = error instanceof Error ? error : new Error(String(error));
176
+ clearNonce();
177
+ callbacks?.onError?.(err);
178
+ }
179
+ }
67
180
  },
68
181
  signIn: {
69
- near: async (params: { recipient: string, signer: Signer }, callbacks?: AuthCallbacks): Promise<VerifyResponseT> => {
182
+ near: async (
183
+ params: { recipient: string },
184
+ callbacks?: AuthCallbacks
185
+ ): Promise<void> => {
70
186
  try {
71
- const { signer, recipient } = params;
187
+ const { recipient } = params;
72
188
 
73
- if (!signer) {
74
- throw new Error("NEAR signer not available");
189
+ if (!nearClient) {
190
+ const error = new Error("NEAR client not available") as Error & { code?: string };
191
+ error.code = "SIGNER_NOT_AVAILABLE";
192
+ throw error;
75
193
  }
76
194
 
77
- // Must be already connected
78
- const accountId = signer.accountId();
195
+ const accountId = nearClient.accountId();
79
196
  if (!accountId) {
80
- throw new Error("Wallet not connected. Please connect your wallet first.");
197
+ const error = new Error("Wallet not connected. Please connect your wallet first.") as Error & { code?: string };
198
+ error.code = "WALLET_NOT_CONNECTED";
199
+ throw error;
81
200
  }
82
201
 
83
- // Get nonce for signature
84
- const nonceResponse: BetterFetchResponse<NonceResponseT> = await $fetch("/near/nonce", {
85
- method: "POST",
86
- body: { accountId }
87
- });
202
+ // Retrieve nonce from cache
203
+ const nonceData = cachedNonce.get();
88
204
 
89
- const nonce = nonceResponse?.data?.nonce;
90
- const message = `Sign in to ${recipient}\n\nAccount ID: ${accountId}\nNonce: ${nonce}`;
205
+ if (!isNonceValid(nonceData)) {
206
+ const error = new Error("No valid nonce found. Please call requestSignIn first.") as Error & { code?: string };
207
+ error.code = "NONCE_NOT_FOUND";
208
+ throw error;
209
+ }
210
+
211
+ // Validate that the cached nonce matches the current account
212
+ if (nonceData!.accountId !== accountId) {
213
+ const error = new Error("Account ID mismatch. Please call requestSignIn again.") as Error & { code?: string };
214
+ error.code = "ACCOUNT_MISMATCH";
215
+ throw error;
216
+ }
91
217
 
92
- // Convert base64 nonce to Uint8Array for signing
93
- const nonceBytes = base64ToBytes(nonce!);
218
+ const { nonce } = nonceData!;
94
219
 
95
- // Sign message
220
+ // Create the sign-in message
221
+ const message = `Sign in to ${recipient}\n\nAccount ID: ${accountId}\nNonce: ${nonce}`;
222
+ const nonceBytes = base64ToBytes(nonce);
223
+
224
+ // Sign the message
96
225
  const authToken = await sign(message, {
97
- signer,
226
+ signer: nearClient,
98
227
  recipient,
99
228
  nonce: nonceBytes,
100
229
  });
101
230
 
102
- // Verify signature with backend
231
+ // Verify the signature with the server
103
232
  const verifyResponse: BetterFetchResponse<VerifyResponseT> = await $fetch("/near/verify", {
104
233
  method: "POST",
105
234
  body: {
@@ -108,17 +237,22 @@ export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
108
237
  }
109
238
  });
110
239
 
240
+ if (verifyResponse.error) {
241
+ throw new Error(verifyResponse.error.message || "Failed to verify signature");
242
+ }
243
+
111
244
  if (!verifyResponse?.data?.success) {
112
245
  throw new Error("Authentication verification failed");
113
246
  }
114
247
 
248
+ // Clear the nonce after successful authentication
249
+ clearNonce();
115
250
  callbacks?.onSuccess?.();
116
- return verifyResponse.data;
117
-
118
251
  } catch (error) {
119
252
  const err = error instanceof Error ? error : new Error(String(error));
253
+ // Clear nonce on error to prevent reuse
254
+ clearNonce();
120
255
  callbacks?.onError?.(err);
121
- throw err;
122
256
  }
123
257
  }
124
258
  }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import { bytesToBase64 } from "@fastnear/utils";
2
2
  import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
3
3
  import { setSessionCookie } from "better-auth/cookies";
4
4
  import type { BetterAuthPlugin, User } from "better-auth/types";
5
- import { generateNonce, verify, type VerificationResult, type VerifyOptions } from "near-sign-verify";
5
+ import { generateNonce, parseAuthToken, verify, type VerificationResult, type VerifyOptions } from "near-sign-verify";
6
6
  import { defaultGetProfile, getImageUrl, getNetworkFromAccountId } from "./profile";
7
7
  import { schema } from "./schema";
8
8
  import type {
@@ -38,10 +38,10 @@ export type SIWNPluginOptions =
38
38
  validateRecipient?: (recipient: string) => boolean;
39
39
  validateMessage?: (message: string) => boolean;
40
40
  getProfile?: (accountId: AccountId) => Promise<Profile | null>;
41
- validateFunctionCallKey?: (args: {
41
+ validateLimitedAccessKey?: (args: {
42
42
  accountId: AccountId;
43
43
  publicKey: string;
44
- contractId?: string;
44
+ recipient?: string;
45
45
  }) => Promise<boolean>;
46
46
  }
47
47
  | {
@@ -54,10 +54,10 @@ export type SIWNPluginOptions =
54
54
  validateRecipient?: (recipient: string) => boolean;
55
55
  validateMessage?: (message: string) => boolean;
56
56
  getProfile?: (accountId: AccountId) => Promise<Profile | null>;
57
- validateFunctionCallKey?: (args: {
57
+ validateLimitedAccessKey?: (args: {
58
58
  accountId: AccountId;
59
59
  publicKey: string;
60
- contractId?: string;
60
+ recipient: string;
61
61
  }) => Promise<boolean>;
62
62
  };
63
63
 
@@ -73,15 +73,23 @@ export const siwn = (options: SIWNPluginOptions) =>
73
73
  body: NonceRequest,
74
74
  },
75
75
  async (ctx) => {
76
- const { accountId } = ctx.body;
76
+ const { accountId, publicKey, networkId } = ctx.body;
77
77
  const network = getNetworkFromAccountId(accountId);
78
+
79
+ if (networkId !== network) {
80
+ throw new APIError("BAD_REQUEST", {
81
+ message: "Network ID mismatch with account ID",
82
+ status: 400,
83
+ });
84
+ }
85
+
78
86
  const nonce = options.getNonce ? await options.getNonce() : generateNonce();
79
87
 
80
88
  // Store nonce as base64 string for database compatibility
81
89
  const nonceString = bytesToBase64(nonce);
82
90
 
83
91
  await ctx.context.internalAdapter.createVerificationValue({
84
- identifier: `siwn:${accountId}:${network}`,
92
+ identifier: `siwn:${accountId}:${network}:${publicKey}`,
85
93
  value: nonceString!,
86
94
  expiresAt: new Date(Date.now() + 15 * 60 * 1000),
87
95
  });
@@ -158,9 +166,11 @@ export const siwn = (options: SIWNPluginOptions) =>
158
166
  }
159
167
 
160
168
  try {
169
+ const { publicKey } = parseAuthToken(authToken);
170
+
161
171
  const verification =
162
172
  await ctx.context.internalAdapter.findVerificationValue(
163
- `siwn:${accountId}:${network}`,
173
+ `siwn:${accountId}:${network}:${publicKey}`,
164
174
  );
165
175
 
166
176
  if (!verification || new Date() > verification.expiresAt) {
@@ -194,13 +204,14 @@ export const siwn = (options: SIWNPluginOptions) =>
194
204
  });
195
205
  }
196
206
 
197
- if (!options.requireFullAccessKey && options.validateFunctionCallKey) {
198
- const isValidFunctionKey = await options.validateFunctionCallKey({
207
+ if (!options.requireFullAccessKey && options.validateLimitedAccessKey) {
208
+ const isValidKey = await options.validateLimitedAccessKey({
199
209
  accountId: result.accountId,
200
210
  publicKey: result.publicKey,
201
- }); // we can validate against an access control contract
211
+ recipient: options.recipient
212
+ }); // we could validate against some access control contract
202
213
 
203
- if (!isValidFunctionKey) {
214
+ if (!isValidKey) {
204
215
  throw new APIError("UNAUTHORIZED", {
205
216
  message: "Unauthorized: Invalid function call access key",
206
217
  status: 401,
package/src/near.test.ts CHANGED
@@ -156,7 +156,7 @@
156
156
  // async verifyMessage({ authToken, expectedRecipient, accountId }) {
157
157
  // return authToken === "valid_token" && expectedRecipient === domain;
158
158
  // },
159
- // async validateFunctionCallKey({ accountId, publicKey }) {
159
+ // async validateLimitedAccessKey({ accountId, publicKey }) {
160
160
  // return accountId === "test.near" && publicKey !== "";
161
161
  // },
162
162
  // }),
package/src/types.ts CHANGED
@@ -34,7 +34,11 @@ export type SocialImage = z.infer<typeof socialImageSchema>;
34
34
  export type Profile = z.infer<typeof profileSchema>;
35
35
 
36
36
 
37
- export const NonceRequest = z.object({ accountId: accountIdSchema });
37
+ export const NonceRequest = z.object({
38
+ accountId: accountIdSchema,
39
+ publicKey: z.string(),
40
+ networkId: z.union([z.literal("mainnet"), z.literal("testnet")])
41
+ });
38
42
  export const VerifyRequest = z.object({
39
43
  authToken: z.string().min(1),
40
44
  accountId: accountIdSchema,