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 +229 -87
- package/package.json +3 -2
- package/src/client.ts +162 -28
- package/src/index.ts +23 -12
- package/src/near.test.ts +1 -1
- package/src/types.ts +5 -1
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: [
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
{ recipient: "myapp.com"
|
|
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
|
-
|
|
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
|
-
* **
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
269
|
+
## API Reference
|
|
148
270
|
|
|
149
|
-
|
|
271
|
+
### Client Actions
|
|
150
272
|
|
|
151
|
-
|
|
152
|
-
import { betterAuth } from "better-auth";
|
|
153
|
-
import { siwn } from "better-near-auth";
|
|
273
|
+
The client plugin provides the following actions:
|
|
154
274
|
|
|
155
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
181
|
-
baseURL: "http://localhost:3000",
|
|
182
|
-
plugins: [siwnClient()],
|
|
183
|
-
});
|
|
184
|
-
```
|
|
284
|
+
#### `authClient.requestSignIn`
|
|
185
285
|
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
const { data: session } = authClient.useSession();
|
|
192
|
-
const [isSigningIn, setIsSigningIn] = useState(false);
|
|
288
|
+
#### `authClient.signIn`
|
|
193
289
|
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
|
|
377
|
+
validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => {
|
|
300
378
|
const allowedContracts = ["myapp.near", "social.near"];
|
|
301
|
-
return
|
|
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.
|
|
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": "
|
|
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
|
|
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
|
|
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:
|
|
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 (
|
|
182
|
+
near: async (
|
|
183
|
+
params: { recipient: string },
|
|
184
|
+
callbacks?: AuthCallbacks
|
|
185
|
+
): Promise<void> => {
|
|
70
186
|
try {
|
|
71
|
-
const {
|
|
187
|
+
const { recipient } = params;
|
|
72
188
|
|
|
73
|
-
if (!
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
const accountId = signer.accountId();
|
|
195
|
+
const accountId = nearClient.accountId();
|
|
79
196
|
if (!accountId) {
|
|
80
|
-
|
|
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
|
-
//
|
|
84
|
-
const
|
|
85
|
-
method: "POST",
|
|
86
|
-
body: { accountId }
|
|
87
|
-
});
|
|
202
|
+
// Retrieve nonce from cache
|
|
203
|
+
const nonceData = cachedNonce.get();
|
|
88
204
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
const nonceBytes = base64ToBytes(nonce!);
|
|
218
|
+
const { nonce } = nonceData!;
|
|
94
219
|
|
|
95
|
-
//
|
|
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
|
|
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
|
-
|
|
41
|
+
validateLimitedAccessKey?: (args: {
|
|
42
42
|
accountId: AccountId;
|
|
43
43
|
publicKey: string;
|
|
44
|
-
|
|
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
|
-
|
|
57
|
+
validateLimitedAccessKey?: (args: {
|
|
58
58
|
accountId: AccountId;
|
|
59
59
|
publicKey: string;
|
|
60
|
-
|
|
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.
|
|
198
|
-
const
|
|
207
|
+
if (!options.requireFullAccessKey && options.validateLimitedAccessKey) {
|
|
208
|
+
const isValidKey = await options.validateLimitedAccessKey({
|
|
199
209
|
accountId: result.accountId,
|
|
200
210
|
publicKey: result.publicKey,
|
|
201
|
-
|
|
211
|
+
recipient: options.recipient
|
|
212
|
+
}); // we could validate against some access control contract
|
|
202
213
|
|
|
203
|
-
if (!
|
|
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
|
|
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({
|
|
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,
|