better-near-auth 0.1.2 → 0.1.4
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 +228 -85
- package/package.json +3 -2
- package/src/client.ts +151 -27
- package/src/index.ts +14 -4
- 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
|
+
}
|
|
81
126
|
|
|
82
|
-
|
|
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
|
+
};
|
|
172
|
+
|
|
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
|
|
@@ -115,7 +233,10 @@ The SIWN plugin accepts the following configuration options:
|
|
|
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,94 +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
|
-
},
|
|
170
|
-
}),
|
|
171
|
-
],
|
|
172
|
-
});
|
|
173
|
-
```
|
|
275
|
+
#### `authClient.near`
|
|
174
276
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
178
283
|
|
|
179
|
-
|
|
180
|
-
baseURL: "http://localhost:3000",
|
|
181
|
-
plugins: [siwnClient()],
|
|
182
|
-
});
|
|
183
|
-
```
|
|
284
|
+
#### `authClient.requestSignIn`
|
|
184
285
|
|
|
185
|
-
|
|
186
|
-
import { authClient } from "./auth-client";
|
|
187
|
-
import { useState } from "react";
|
|
286
|
+
- `near(params, callbacks?)` - Connect wallet and cache nonce (Step 1)
|
|
188
287
|
|
|
189
|
-
|
|
190
|
-
const { data: session } = authClient.useSession();
|
|
191
|
-
const [isSigningIn, setIsSigningIn] = useState(false);
|
|
288
|
+
#### `authClient.signIn`
|
|
192
289
|
|
|
193
|
-
|
|
194
|
-
return (
|
|
195
|
-
<div>
|
|
196
|
-
<p>Welcome, {session.user.name}!</p>
|
|
197
|
-
<button onClick={() => authClient.signOut()}>Sign out</button>
|
|
198
|
-
</div>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
290
|
+
- `near(params, callbacks?)` - Sign message and authenticate (Step 2)
|
|
201
291
|
|
|
202
|
-
|
|
203
|
-
setIsSigningIn(true);
|
|
204
|
-
|
|
205
|
-
try {
|
|
206
|
-
await authClient.signIn.near(
|
|
207
|
-
{ recipient: "myapp.com", signer: window.near },
|
|
208
|
-
{
|
|
209
|
-
onSuccess: () => {
|
|
210
|
-
console.log("Successfully signed in!");
|
|
211
|
-
},
|
|
212
|
-
onError: (error) => {
|
|
213
|
-
console.error("Sign in failed:", error.message);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
);
|
|
217
|
-
} catch (error) {
|
|
218
|
-
console.error("Authentication error:", error);
|
|
219
|
-
} finally {
|
|
220
|
-
setIsSigningIn(false);
|
|
221
|
-
}
|
|
222
|
-
};
|
|
292
|
+
### Callback Interface
|
|
223
293
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
);
|
|
294
|
+
```typescript
|
|
295
|
+
interface AuthCallbacks {
|
|
296
|
+
onSuccess?: () => void;
|
|
297
|
+
onError?: (error: Error & { status?: number; code?: string }) => void;
|
|
229
298
|
}
|
|
230
299
|
```
|
|
231
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
|
+
|
|
232
311
|
## Advanced Configuration
|
|
233
312
|
|
|
234
|
-
For advanced use cases, you can customize the validation functions
|
|
313
|
+
For advanced use cases, you can customize the validation functions:
|
|
235
314
|
|
|
236
315
|
```ts title="advanced-auth.ts"
|
|
237
316
|
import { betterAuth } from "better-auth";
|
|
@@ -295,15 +374,78 @@ export const auth = betterAuth({
|
|
|
295
374
|
},
|
|
296
375
|
|
|
297
376
|
// Validate function call keys against allowed contracts
|
|
298
|
-
validateLimitedAccessKey: async ({ accountId, publicKey,
|
|
377
|
+
validateLimitedAccessKey: async ({ accountId, publicKey, recipient }) => {
|
|
299
378
|
const allowedContracts = ["myapp.near", "social.near"];
|
|
300
|
-
return
|
|
379
|
+
return recipient ? allowedContracts.includes(recipient) : true;
|
|
301
380
|
},
|
|
302
381
|
}),
|
|
303
382
|
],
|
|
304
383
|
});
|
|
305
384
|
```
|
|
306
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
|
+
|
|
307
449
|
## Links
|
|
308
450
|
|
|
309
451
|
* [Better Auth Documentation](https://better-auth.com)
|
|
@@ -311,3 +453,4 @@ export const auth = betterAuth({
|
|
|
311
453
|
* [NEP-413 Specification](https://github.com/near/NEPs/blob/master/neps/nep-0413.md)
|
|
312
454
|
* [near-sign-verify](https://github.com/elliotBraem/near-sign-verify)
|
|
313
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.4",
|
|
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.
|
|
48
|
+
"fastintear": "^0.2.4",
|
|
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,14 +1,10 @@
|
|
|
1
1
|
import { base64ToBytes } from "@fastnear/utils";
|
|
2
|
-
import type { BetterAuthClientPlugin, BetterFetchOption, BetterFetchResponse } from "better-auth/client";
|
|
3
|
-
import {
|
|
2
|
+
import type { BetterAuthClientPlugin, BetterFetch, BetterFetchOption, BetterFetchResponse } from "better-auth/client";
|
|
3
|
+
import { createNearClient } from "fastintear";
|
|
4
|
+
import { atom } from "nanostores";
|
|
5
|
+
import { sign } from "near-sign-verify";
|
|
4
6
|
import type { siwn } from ".";
|
|
5
7
|
import { type AccountId, type NonceRequestT, type NonceResponseT, type ProfileResponseT, type VerifyRequestT, type VerifyResponseT } from "./types";
|
|
6
|
-
import type { User } from "better-auth";
|
|
7
|
-
|
|
8
|
-
export interface Signer {
|
|
9
|
-
accountId(): string | null;
|
|
10
|
-
signMessage: WalletInterface["signMessage"];
|
|
11
|
-
}
|
|
12
8
|
|
|
13
9
|
export interface AuthCallbacks {
|
|
14
10
|
onSuccess?: () => void;
|
|
@@ -16,7 +12,17 @@ export interface AuthCallbacks {
|
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
export interface SIWNClientConfig {
|
|
19
|
-
domain: string;
|
|
15
|
+
domain: string; // TODO: this could potentially be shade agent proxy or something, doesn't really have any purpose rn
|
|
16
|
+
networkId?: "mainnet" | "testnet";
|
|
17
|
+
// TODO: should include browser vs keypair
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CachedNonceData {
|
|
21
|
+
nonce: string;
|
|
22
|
+
accountId: string;
|
|
23
|
+
publicKey: string;
|
|
24
|
+
networkId: string;
|
|
25
|
+
timestamp: number;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
28
|
export interface SIWNClientActions {
|
|
@@ -24,19 +30,44 @@ export interface SIWNClientActions {
|
|
|
24
30
|
nonce: (params: NonceRequestT) => Promise<BetterFetchResponse<NonceResponseT>>;
|
|
25
31
|
verify: (params: VerifyRequestT) => Promise<BetterFetchResponse<VerifyResponseT>>;
|
|
26
32
|
getProfile: (accountId?: AccountId) => Promise<BetterFetchResponse<ProfileResponseT>>;
|
|
33
|
+
getNearClient: () => ReturnType<typeof createNearClient>;
|
|
34
|
+
getAccountId: () => string | null;
|
|
35
|
+
disconnect: () => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
requestSignIn: {
|
|
38
|
+
near: (params: { recipient: string }, callbacks?: AuthCallbacks) => Promise<void>;
|
|
27
39
|
};
|
|
28
40
|
signIn: {
|
|
29
|
-
near: (params: { recipient: string
|
|
41
|
+
near: (params: { recipient: string }, callbacks?: AuthCallbacks) => Promise<void>;
|
|
30
42
|
};
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
export interface SIWNClientPlugin extends BetterAuthClientPlugin {
|
|
34
46
|
id: "siwn";
|
|
35
47
|
$InferServerPlugin: ReturnType<typeof siwn>;
|
|
36
|
-
getActions: ($fetch:
|
|
48
|
+
getActions: ($fetch: BetterFetch) => SIWNClientActions;
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
|
|
52
|
+
// Create embedded NEAR client
|
|
53
|
+
const nearClient = createNearClient({
|
|
54
|
+
networkId: config.networkId || "mainnet"
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Create atoms for caching nonce only
|
|
58
|
+
const cachedNonce = atom<CachedNonceData | null>(null);
|
|
59
|
+
|
|
60
|
+
const clearNonce = () => {
|
|
61
|
+
cachedNonce.set(null);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isNonceValid = (nonceData: CachedNonceData | null): boolean => {
|
|
65
|
+
if (!nonceData) return false;
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
const fiveMinutes = 5 * 60 * 1000;
|
|
68
|
+
return (now - nonceData.timestamp) < fiveMinutes;
|
|
69
|
+
};
|
|
70
|
+
|
|
40
71
|
return {
|
|
41
72
|
id: "siwn",
|
|
42
73
|
$InferServerPlugin: {} as ReturnType<typeof siwn>,
|
|
@@ -63,45 +94,134 @@ export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
|
|
|
63
94
|
body: { accountId },
|
|
64
95
|
...fetchOptions
|
|
65
96
|
});
|
|
66
|
-
|
|
97
|
+
},
|
|
98
|
+
getNearClient: () => nearClient,
|
|
99
|
+
getAccountId: () => nearClient.accountId(),
|
|
100
|
+
disconnect: async () => {
|
|
101
|
+
await nearClient.signOut();
|
|
102
|
+
clearNonce();
|
|
67
103
|
},
|
|
68
104
|
},
|
|
105
|
+
requestSignIn: {
|
|
106
|
+
near: async (
|
|
107
|
+
params: { recipient: string },
|
|
108
|
+
callbacks?: AuthCallbacks
|
|
109
|
+
): Promise<void> => {
|
|
110
|
+
try {
|
|
111
|
+
const { recipient } = params;
|
|
112
|
+
|
|
113
|
+
if (!nearClient) {
|
|
114
|
+
const error = new Error("NEAR client not available") as Error & { code?: string };
|
|
115
|
+
error.code = "SIGNER_NOT_AVAILABLE";
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
clearNonce();
|
|
120
|
+
|
|
121
|
+
await nearClient.requestSignIn({ contractId: recipient }, {
|
|
122
|
+
onSuccess: async ({ accountId, publicKey, networkId }: { accountId: string, publicKey: string, networkId: string }) => {
|
|
123
|
+
try {
|
|
124
|
+
const nonceRequest: NonceRequestT = {
|
|
125
|
+
accountId,
|
|
126
|
+
publicKey,
|
|
127
|
+
networkId: networkId as "mainnet" | "testnet"
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const nonceResponse: BetterFetchResponse<NonceResponseT> = await $fetch("/near/nonce", {
|
|
131
|
+
method: "POST",
|
|
132
|
+
body: nonceRequest
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (nonceResponse.error) {
|
|
136
|
+
throw new Error(nonceResponse.error.message || "Failed to get nonce");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const nonce = nonceResponse?.data?.nonce;
|
|
140
|
+
if (!nonce) {
|
|
141
|
+
throw new Error("No nonce received from server");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Cache nonce with all wallet data
|
|
145
|
+
const cachedData: CachedNonceData = {
|
|
146
|
+
nonce,
|
|
147
|
+
accountId,
|
|
148
|
+
publicKey,
|
|
149
|
+
networkId,
|
|
150
|
+
timestamp: Date.now()
|
|
151
|
+
};
|
|
152
|
+
cachedNonce.set(cachedData);
|
|
153
|
+
|
|
154
|
+
callbacks?.onSuccess?.();
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
157
|
+
clearNonce();
|
|
158
|
+
callbacks?.onError?.(err);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
onError: (error: any) => {
|
|
162
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
163
|
+
clearNonce();
|
|
164
|
+
callbacks?.onError?.(err);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
169
|
+
clearNonce();
|
|
170
|
+
callbacks?.onError?.(err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
69
174
|
signIn: {
|
|
70
175
|
near: async (
|
|
71
|
-
params: { recipient: string
|
|
176
|
+
params: { recipient: string },
|
|
72
177
|
callbacks?: AuthCallbacks
|
|
73
178
|
): Promise<void> => {
|
|
74
179
|
try {
|
|
75
|
-
const {
|
|
180
|
+
const { recipient } = params;
|
|
76
181
|
|
|
77
|
-
if (!
|
|
78
|
-
|
|
182
|
+
if (!nearClient) {
|
|
183
|
+
const error = new Error("NEAR client not available") as Error & { code?: string };
|
|
184
|
+
error.code = "SIGNER_NOT_AVAILABLE";
|
|
185
|
+
throw error;
|
|
79
186
|
}
|
|
80
187
|
|
|
81
|
-
const accountId =
|
|
188
|
+
const accountId = nearClient.accountId();
|
|
82
189
|
if (!accountId) {
|
|
83
|
-
|
|
190
|
+
const error = new Error("Wallet not connected. Please connect your wallet first.") as Error & { code?: string };
|
|
191
|
+
error.code = "WALLET_NOT_CONNECTED";
|
|
192
|
+
throw error;
|
|
84
193
|
}
|
|
85
194
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
195
|
+
// Retrieve nonce from cache
|
|
196
|
+
const nonceData = cachedNonce.get();
|
|
197
|
+
|
|
198
|
+
if (!isNonceValid(nonceData)) {
|
|
199
|
+
const error = new Error("No valid nonce found. Please call requestSignIn first.") as Error & { code?: string };
|
|
200
|
+
error.code = "NONCE_NOT_FOUND";
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
90
203
|
|
|
91
|
-
|
|
92
|
-
|
|
204
|
+
// Validate that the cached nonce matches the current account
|
|
205
|
+
if (nonceData!.accountId !== accountId) {
|
|
206
|
+
const error = new Error("Account ID mismatch. Please call requestSignIn again.") as Error & { code?: string };
|
|
207
|
+
error.code = "ACCOUNT_MISMATCH";
|
|
208
|
+
throw error;
|
|
93
209
|
}
|
|
94
210
|
|
|
95
|
-
const nonce =
|
|
211
|
+
const { nonce } = nonceData!;
|
|
212
|
+
|
|
213
|
+
// Create the sign-in message
|
|
96
214
|
const message = `Sign in to ${recipient}\n\nAccount ID: ${accountId}\nNonce: ${nonce}`;
|
|
97
|
-
const nonceBytes = base64ToBytes(nonce
|
|
215
|
+
const nonceBytes = base64ToBytes(nonce);
|
|
98
216
|
|
|
217
|
+
// Sign the message
|
|
99
218
|
const authToken = await sign(message, {
|
|
100
|
-
signer,
|
|
219
|
+
signer: nearClient,
|
|
101
220
|
recipient,
|
|
102
221
|
nonce: nonceBytes,
|
|
103
222
|
});
|
|
104
223
|
|
|
224
|
+
// Verify the signature with the server
|
|
105
225
|
const verifyResponse: BetterFetchResponse<VerifyResponseT> = await $fetch("/near/verify", {
|
|
106
226
|
method: "POST",
|
|
107
227
|
body: {
|
|
@@ -118,9 +238,13 @@ export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
|
|
|
118
238
|
throw new Error("Authentication verification failed");
|
|
119
239
|
}
|
|
120
240
|
|
|
241
|
+
// Clear the nonce after successful authentication
|
|
242
|
+
clearNonce();
|
|
121
243
|
callbacks?.onSuccess?.();
|
|
122
244
|
} catch (error) {
|
|
123
245
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
246
|
+
// Clear nonce on error to prevent reuse
|
|
247
|
+
clearNonce();
|
|
124
248
|
callbacks?.onError?.(err);
|
|
125
249
|
}
|
|
126
250
|
}
|
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 {
|
|
@@ -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) {
|
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,
|