better-near-auth 0.1.0
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 +311 -0
- package/index.ts +22 -0
- package/package.json +65 -0
- package/src/client.ts +128 -0
- package/src/index.ts +351 -0
- package/src/near.test.ts +331 -0
- package/src/profile.ts +63 -0
- package/src/schema.ts +36 -0
- package/src/types.ts +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
<!-- markdownlint-disable MD014 -->
|
|
2
|
+
<!-- markdownlint-disable MD033 -->
|
|
3
|
+
<!-- markdownlint-disable MD041 -->
|
|
4
|
+
<!-- markdownlint-disable MD029 -->
|
|
5
|
+
|
|
6
|
+
<div align="center">
|
|
7
|
+
|
|
8
|
+
<h1 style="font-size: 2.5rem; font-weight: bold;">better-near-auth</h1>
|
|
9
|
+
|
|
10
|
+
<p>
|
|
11
|
+
<strong>Sign in with NEAR (SIWN) plugin for better-auth</strong>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
This [Better Auth](https://better-auth.com) plugin enables secure authentication via NEAR wallets and keypairs by following the [NEP-413 standard](https://github.com/near/NEPs/blob/master/neps/nep-0413.md). It leverages [near-sign-verify](https://github.com/elliotBraem/near-sign-verify) and [fastintear](https://github.com/elliotBraem/fastintear), and provides a complete drop-in solution with secure defaults and automatic profile integration.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
1. Install the package
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install better-near-auth
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
2. Add the SIWN plugin to your auth configuration:
|
|
27
|
+
|
|
28
|
+
```ts title="auth.ts"
|
|
29
|
+
import { betterAuth } from "better-auth";
|
|
30
|
+
import { siwn } from "better-near-auth";
|
|
31
|
+
|
|
32
|
+
export const auth = betterAuth({
|
|
33
|
+
plugins: [
|
|
34
|
+
siwn({
|
|
35
|
+
recipient: "myapp.com",
|
|
36
|
+
anonymous: true, // optional, default is true
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
3. Migrate the database. Run the migration or generate the schema to add the necessary fields and tables to the database.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @better-auth/cli generate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
4. Add the Client Plugin
|
|
49
|
+
|
|
50
|
+
```ts title="auth-client.ts"
|
|
51
|
+
import { createAuthClient } from "better-auth/client";
|
|
52
|
+
import { siwnClient } from "better-near-auth/client";
|
|
53
|
+
|
|
54
|
+
export const authClient = createAuthClient({
|
|
55
|
+
plugins: [siwnClient()],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
### One-Line Authentication
|
|
63
|
+
|
|
64
|
+
The simplest way to authenticate with NEAR:
|
|
65
|
+
|
|
66
|
+
```ts title="sign-in-near.ts"
|
|
67
|
+
const response = await authClient.signIn.near(
|
|
68
|
+
{ recipient: "myapp.com", signer: window.near },
|
|
69
|
+
{
|
|
70
|
+
onSuccess: () => {
|
|
71
|
+
console.log("Successfully signed in!");
|
|
72
|
+
},
|
|
73
|
+
onError: (error) => {
|
|
74
|
+
console.error("Sign in failed:", error.message);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
console.log("Signed in as:", response.user.accountId);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Profile Access
|
|
83
|
+
|
|
84
|
+
Access user profiles from NEAR Social automatically:
|
|
85
|
+
|
|
86
|
+
```ts title="profile-usage.ts"
|
|
87
|
+
// Get current user's profile (requires authentication)
|
|
88
|
+
const { data: myProfile } = await authClient.near.getProfile();
|
|
89
|
+
console.log("My profile:", myProfile);
|
|
90
|
+
|
|
91
|
+
// Get specific user's profile (no auth required)
|
|
92
|
+
const { data: aliceProfile } = await authClient.near.getProfile("alice.near");
|
|
93
|
+
console.log("Alice's profile:", aliceProfile);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Configuration Options
|
|
97
|
+
|
|
98
|
+
### Server Options
|
|
99
|
+
|
|
100
|
+
The SIWN plugin accepts the following configuration options:
|
|
101
|
+
|
|
102
|
+
* **recipient**: The recipient identifier for NEP-413 messages (required)
|
|
103
|
+
* **anonymous**: Whether to allow anonymous sign-ins without requiring an email. Default is `true`
|
|
104
|
+
* **emailDomainName**: The email domain name for creating user accounts when not using anonymous mode. Defaults to the recipient value
|
|
105
|
+
* **requireFullAccessKey**: Whether to require full access keys. Default is `true`
|
|
106
|
+
* **getNonce**: Function to generate a unique nonce for each sign-in attempt. Optional, uses secure defaults
|
|
107
|
+
* **validateNonce**: Function to validate nonces. Optional, uses time-based validation by default
|
|
108
|
+
* **validateRecipient**: Function to validate recipients. Optional, uses exact match by default
|
|
109
|
+
* **validateMessage**: Function to validate messages. Optional, no validation by default
|
|
110
|
+
* **getProfile**: Function to fetch user profiles. Optional, uses NEAR Social by default
|
|
111
|
+
* **validateFunctionCallKey**: Function to validate function call access keys when `requireFullAccessKey` is false
|
|
112
|
+
|
|
113
|
+
### Client Options
|
|
114
|
+
|
|
115
|
+
The SIWN client plugin doesn't require any configuration options:
|
|
116
|
+
|
|
117
|
+
```ts title="auth-client.ts"
|
|
118
|
+
import { createAuthClient } from "better-auth/client";
|
|
119
|
+
import { siwnClient } from "better-near-auth/client";
|
|
120
|
+
|
|
121
|
+
export const authClient = createAuthClient({
|
|
122
|
+
plugins: [
|
|
123
|
+
siwnClient({
|
|
124
|
+
// Optional client configuration can go here
|
|
125
|
+
}),
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Schema
|
|
131
|
+
|
|
132
|
+
The SIWN plugin adds a `nearAccount` table to store user NEAR account associations:
|
|
133
|
+
|
|
134
|
+
| Field | Type | Description |
|
|
135
|
+
| --------- | ------- | ----------------------------------------- |
|
|
136
|
+
| id | string | Primary key |
|
|
137
|
+
| userId | string | Reference to user.id |
|
|
138
|
+
| accountId | string | NEAR account ID |
|
|
139
|
+
| network | string | Network (mainnet or testnet) |
|
|
140
|
+
| publicKey | string | Associated public key |
|
|
141
|
+
| isPrimary | boolean | Whether this is the user's primary account|
|
|
142
|
+
| createdAt | date | Creation timestamp |
|
|
143
|
+
|
|
144
|
+
## Complete Implementation Example
|
|
145
|
+
|
|
146
|
+
Here's a complete example showing how to implement SIWN authentication:
|
|
147
|
+
|
|
148
|
+
```ts title="auth.ts"
|
|
149
|
+
import { betterAuth } from "better-auth";
|
|
150
|
+
import { siwn } from "better-near-auth";
|
|
151
|
+
|
|
152
|
+
export const auth = betterAuth({
|
|
153
|
+
database: {
|
|
154
|
+
provider: "sqlite",
|
|
155
|
+
url: "./db.sqlite"
|
|
156
|
+
},
|
|
157
|
+
plugins: [
|
|
158
|
+
siwn({
|
|
159
|
+
recipient: "myapp.com",
|
|
160
|
+
anonymous: false, // Require email for users
|
|
161
|
+
emailDomainName: "myapp.com",
|
|
162
|
+
|
|
163
|
+
// Optional: Custom profile lookup
|
|
164
|
+
getProfile: async (accountId) => {
|
|
165
|
+
// Custom profile logic, falls back to NEAR Social
|
|
166
|
+
return null; // Use default NEAR Social lookup
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
```ts title="auth-client.ts"
|
|
174
|
+
import { createAuthClient } from "better-auth/client";
|
|
175
|
+
import { siwnClient } from "better-near-auth/client";
|
|
176
|
+
|
|
177
|
+
export const authClient = createAuthClient({
|
|
178
|
+
baseURL: "http://localhost:3000",
|
|
179
|
+
plugins: [siwnClient()],
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```tsx title="LoginButton.tsx"
|
|
184
|
+
import { authClient } from "./auth-client";
|
|
185
|
+
import { useState } from "react";
|
|
186
|
+
|
|
187
|
+
export function LoginButton() {
|
|
188
|
+
const { data: session } = authClient.useSession();
|
|
189
|
+
const [isSigningIn, setIsSigningIn] = useState(false);
|
|
190
|
+
|
|
191
|
+
if (session) {
|
|
192
|
+
return (
|
|
193
|
+
<div>
|
|
194
|
+
<p>Welcome, {session.user.name}!</p>
|
|
195
|
+
<button onClick={() => authClient.signOut()}>Sign out</button>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const handleSignIn = async () => {
|
|
201
|
+
setIsSigningIn(true);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
await authClient.signIn.near(
|
|
205
|
+
{ recipient: "myapp.com", signer: window.near },
|
|
206
|
+
{
|
|
207
|
+
onSuccess: () => {
|
|
208
|
+
console.log("Successfully signed in!");
|
|
209
|
+
},
|
|
210
|
+
onError: (error) => {
|
|
211
|
+
console.error("Sign in failed:", error.message);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error("Authentication error:", error);
|
|
217
|
+
} finally {
|
|
218
|
+
setIsSigningIn(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<button onClick={handleSignIn} disabled={isSigningIn}>
|
|
224
|
+
{isSigningIn ? "Signing in..." : "Sign in with NEAR"}
|
|
225
|
+
</button>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Advanced Configuration
|
|
231
|
+
|
|
232
|
+
For advanced use cases, you can customize the validation functions passed to `verify` in `near-sign-verify`:
|
|
233
|
+
|
|
234
|
+
```ts title="advanced-auth.ts"
|
|
235
|
+
import { betterAuth } from "better-auth";
|
|
236
|
+
import { siwn } from "better-near-auth";
|
|
237
|
+
import { generateNonce } from "near-sign-verify";
|
|
238
|
+
|
|
239
|
+
const usedNonces = new Set<string>();
|
|
240
|
+
|
|
241
|
+
export const auth = betterAuth({
|
|
242
|
+
plugins: [
|
|
243
|
+
siwn({
|
|
244
|
+
recipient: "myapp.com",
|
|
245
|
+
anonymous: false, // Require email for users
|
|
246
|
+
emailDomainName: "myapp.com",
|
|
247
|
+
requireFullAccessKey: false, // Allow function call keys
|
|
248
|
+
|
|
249
|
+
// Custom nonce generation
|
|
250
|
+
getNonce: async () => {
|
|
251
|
+
return generateNonce();
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// Custom nonce validation (prevents replay attacks)
|
|
255
|
+
validateNonce: (nonce: Uint8Array) => {
|
|
256
|
+
const nonceHex = Array.from(nonce).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
257
|
+
if (usedNonces.has(nonceHex)) {
|
|
258
|
+
return false; // Prevent replay attacks
|
|
259
|
+
}
|
|
260
|
+
usedNonces.add(nonceHex);
|
|
261
|
+
return true;
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
// Custom recipient validation (allow multiple domains)
|
|
265
|
+
validateRecipient: (recipient: string) => {
|
|
266
|
+
const allowedRecipients = ["myapp.com", "staging.myapp.com", "localhost:3000"];
|
|
267
|
+
return allowedRecipients.includes(recipient);
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
// Custom message validation
|
|
271
|
+
validateMessage: (message: string) => {
|
|
272
|
+
// Add custom message format validation
|
|
273
|
+
return message.includes("Sign in to") && message.length > 10;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// Custom profile lookup
|
|
277
|
+
getProfile: async (accountId) => {
|
|
278
|
+
// Custom profile logic, falls back to NEAR Social
|
|
279
|
+
try {
|
|
280
|
+
const response = await fetch(`https://api.myapp.com/profiles/${accountId}`);
|
|
281
|
+
if (response.ok) {
|
|
282
|
+
const customProfile = await response.json();
|
|
283
|
+
return {
|
|
284
|
+
name: customProfile.displayName,
|
|
285
|
+
description: customProfile.bio,
|
|
286
|
+
image: { url: customProfile.avatar },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
console.error("Custom profile fetch failed:", error);
|
|
291
|
+
}
|
|
292
|
+
return null; // Use default NEAR Social lookup
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
// Validate function call keys against allowed contracts
|
|
296
|
+
validateFunctionCallKey: async ({ accountId, publicKey, contractId }) => {
|
|
297
|
+
const allowedContracts = ["myapp.near", "social.near"];
|
|
298
|
+
return contractId ? allowedContracts.includes(contractId) : true;
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## Links
|
|
306
|
+
|
|
307
|
+
* [Better Auth Documentation](https://better-auth.com)
|
|
308
|
+
* [NEAR Protocol](https://near.org)
|
|
309
|
+
* [NEP-413 Specification](https://github.com/near/NEPs/blob/master/neps/nep-0413.md)
|
|
310
|
+
* [near-sign-verify](https://github.com/elliotBraem/near-sign-verify)
|
|
311
|
+
* [fastintear](https://github.com/elliotBraem/fastintear)
|
package/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { siwn } from "./src/index";
|
|
2
|
+
export { siwnClient } from "./src/client";
|
|
3
|
+
export type {
|
|
4
|
+
AccountId,
|
|
5
|
+
NearAccount,
|
|
6
|
+
SocialImage,
|
|
7
|
+
Profile,
|
|
8
|
+
NonceRequestT,
|
|
9
|
+
NonceResponseT,
|
|
10
|
+
VerifyRequestT,
|
|
11
|
+
VerifyResponseT,
|
|
12
|
+
ProfileRequestT,
|
|
13
|
+
ProfileResponseT,
|
|
14
|
+
} from "./src/types";
|
|
15
|
+
export { accountIdSchema } from "./src/types";
|
|
16
|
+
export type { SIWNPluginOptions } from "./src/index";
|
|
17
|
+
export type {
|
|
18
|
+
AuthCallbacks,
|
|
19
|
+
SIWNClientConfig,
|
|
20
|
+
SIWNClientActions,
|
|
21
|
+
SIWNClientPlugin,
|
|
22
|
+
} from "./src/client";
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "better-near-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sign in with NEAR (SIWN) plugin for Better Auth",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"module": "index.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./index.ts",
|
|
11
|
+
"types": "./index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc --noEmit",
|
|
16
|
+
"test": "vitest",
|
|
17
|
+
"test:watch": "vitest --watch",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"dev": "bun run index.ts"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"better-auth",
|
|
23
|
+
"near",
|
|
24
|
+
"siwn",
|
|
25
|
+
"sign-in-with-near",
|
|
26
|
+
"authentication",
|
|
27
|
+
"blockchain",
|
|
28
|
+
"web3",
|
|
29
|
+
"plugin"
|
|
30
|
+
],
|
|
31
|
+
"author": "efiz.near",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/elliotBraem/better-near-auth.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/elliotBraem/better-near-auth/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/elliotBraem/better-near-auth#readme",
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"better-auth": "^1.0.0",
|
|
43
|
+
"typescript": "^5.0.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@better-auth/utils": "^0.2.6",
|
|
47
|
+
"@fastnear/utils": "^0.9.7",
|
|
48
|
+
"fastintear": "link:fastintear",
|
|
49
|
+
"near-sign-verify": "^0.4.3",
|
|
50
|
+
"zod": "^4.0.17"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/bun": "latest",
|
|
54
|
+
"@types/node": "^24.3.0",
|
|
55
|
+
"better-auth": "^1.3.6",
|
|
56
|
+
"typescript": "^5.9.2",
|
|
57
|
+
"vitest": "^3.2.4"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"src/",
|
|
61
|
+
"index.ts",
|
|
62
|
+
"README.md",
|
|
63
|
+
"LICENSE"
|
|
64
|
+
]
|
|
65
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { base64ToBytes } from "@fastnear/utils";
|
|
2
|
+
import type { BetterAuthClientPlugin, BetterFetchOption, BetterFetchResponse } from "better-auth/client";
|
|
3
|
+
import { sign, type WalletInterface } from "near-sign-verify";
|
|
4
|
+
import type { siwn } from ".";
|
|
5
|
+
import { type AccountId, type NonceRequestT, type NonceResponseT, type ProfileResponseT, type VerifyRequestT, type VerifyResponseT } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface Signer {
|
|
8
|
+
accountId(): string | null;
|
|
9
|
+
signMessage: WalletInterface["signMessage"];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AuthCallbacks {
|
|
13
|
+
onSuccess?: () => void;
|
|
14
|
+
onError?: (error: Error & { status?: number; code?: string }) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SIWNClientConfig {
|
|
18
|
+
domain: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SIWNClientActions {
|
|
22
|
+
near: {
|
|
23
|
+
nonce: (params: NonceRequestT) => Promise<BetterFetchResponse<NonceResponseT>>;
|
|
24
|
+
verify: (params: VerifyRequestT) => Promise<BetterFetchResponse<VerifyResponseT>>;
|
|
25
|
+
getProfile: (accountId?: AccountId) => Promise<BetterFetchResponse<ProfileResponseT>>;
|
|
26
|
+
};
|
|
27
|
+
signIn: {
|
|
28
|
+
near: (params: { recipient: string, signer: Signer }, callbacks?: AuthCallbacks) => Promise<VerifyResponseT>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SIWNClientPlugin extends BetterAuthClientPlugin {
|
|
33
|
+
id: "siwn";
|
|
34
|
+
$InferServerPlugin: ReturnType<typeof siwn>;
|
|
35
|
+
getActions: ($fetch: any) => SIWNClientActions;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
|
|
39
|
+
return {
|
|
40
|
+
id: "siwn",
|
|
41
|
+
$InferServerPlugin: {} as ReturnType<typeof siwn>,
|
|
42
|
+
getActions: ($fetch): SIWNClientActions => {
|
|
43
|
+
return {
|
|
44
|
+
near: {
|
|
45
|
+
nonce: async (params: NonceRequestT, fetchOptions?: BetterFetchOption): Promise<BetterFetchResponse<NonceResponseT>> => {
|
|
46
|
+
return await $fetch("/near/nonce", {
|
|
47
|
+
method: "POST",
|
|
48
|
+
body: params,
|
|
49
|
+
...fetchOptions
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
verify: async (params: VerifyRequestT, fetchOptions?: BetterFetchOption): Promise<BetterFetchResponse<VerifyResponseT>> => {
|
|
53
|
+
return await $fetch("/near/verify", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: params,
|
|
56
|
+
...fetchOptions
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
getProfile: async (accountId?: AccountId, fetchOptions?: BetterFetchOption): Promise<BetterFetchResponse<ProfileResponseT>> => {
|
|
60
|
+
return await $fetch("/near/profile", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: { accountId },
|
|
63
|
+
...fetchOptions
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
signIn: {
|
|
69
|
+
near: async (params: { recipient: string, signer: Signer }, callbacks?: AuthCallbacks): Promise<VerifyResponseT> => {
|
|
70
|
+
try {
|
|
71
|
+
const { signer, recipient } = params;
|
|
72
|
+
|
|
73
|
+
if (!signer) {
|
|
74
|
+
throw new Error("NEAR signer not available");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Must be already connected
|
|
78
|
+
const accountId = signer.accountId();
|
|
79
|
+
if (!accountId) {
|
|
80
|
+
throw new Error("Wallet not connected. Please connect your wallet first.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get nonce for signature
|
|
84
|
+
const nonceResponse: BetterFetchResponse<NonceResponseT> = await $fetch("/near/nonce", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: { accountId }
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const nonce = nonceResponse?.data?.nonce;
|
|
90
|
+
const message = `Sign in to ${recipient}\n\nAccount ID: ${accountId}\nNonce: ${nonce}`;
|
|
91
|
+
|
|
92
|
+
// Convert base64 nonce to Uint8Array for signing
|
|
93
|
+
const nonceBytes = base64ToBytes(nonce!);
|
|
94
|
+
|
|
95
|
+
// Sign message
|
|
96
|
+
const authToken = await sign(message, {
|
|
97
|
+
signer,
|
|
98
|
+
recipient,
|
|
99
|
+
nonce: nonceBytes,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Verify signature with backend
|
|
103
|
+
const verifyResponse: BetterFetchResponse<VerifyResponseT> = await $fetch("/near/verify", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: {
|
|
106
|
+
authToken,
|
|
107
|
+
accountId,
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!verifyResponse?.data?.success) {
|
|
112
|
+
throw new Error("Authentication verification failed");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
callbacks?.onSuccess?.();
|
|
116
|
+
return verifyResponse.data;
|
|
117
|
+
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
120
|
+
callbacks?.onError?.(err);
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
} satisfies BetterAuthClientPlugin;
|
|
128
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { bytesToBase64 } from "@fastnear/utils";
|
|
2
|
+
import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
|
|
3
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
4
|
+
import type { BetterAuthPlugin, User } from "better-auth/types";
|
|
5
|
+
import { generateNonce, verify, type VerificationResult, type VerifyOptions } from "near-sign-verify";
|
|
6
|
+
import { defaultGetProfile, getImageUrl, getNetworkFromAccountId } from "./profile";
|
|
7
|
+
import { schema } from "./schema";
|
|
8
|
+
import type {
|
|
9
|
+
AccountId,
|
|
10
|
+
NearAccount,
|
|
11
|
+
Profile
|
|
12
|
+
} from "./types";
|
|
13
|
+
import {
|
|
14
|
+
NonceRequest,
|
|
15
|
+
NonceResponse,
|
|
16
|
+
ProfileRequest,
|
|
17
|
+
ProfileResponse,
|
|
18
|
+
VerifyRequest,
|
|
19
|
+
VerifyResponse
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
function getOrigin(baseURL: string): string {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(baseURL).origin;
|
|
25
|
+
} catch {
|
|
26
|
+
return baseURL;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type SIWNPluginOptions =
|
|
31
|
+
| {
|
|
32
|
+
recipient: string;
|
|
33
|
+
anonymous?: true;
|
|
34
|
+
emailDomainName?: string;
|
|
35
|
+
requireFullAccessKey?: boolean;
|
|
36
|
+
getNonce?: () => Promise<Uint8Array>;
|
|
37
|
+
validateNonce?: (nonce: Uint8Array) => boolean;
|
|
38
|
+
validateRecipient?: (recipient: string) => boolean;
|
|
39
|
+
validateMessage?: (message: string) => boolean;
|
|
40
|
+
getProfile?: (accountId: AccountId) => Promise<Profile | null>;
|
|
41
|
+
validateFunctionCallKey?: (args: {
|
|
42
|
+
accountId: AccountId;
|
|
43
|
+
publicKey: string;
|
|
44
|
+
contractId?: string;
|
|
45
|
+
}) => Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
recipient: string;
|
|
49
|
+
anonymous: false;
|
|
50
|
+
emailDomainName?: string;
|
|
51
|
+
requireFullAccessKey?: boolean;
|
|
52
|
+
getNonce?: () => Promise<Uint8Array>;
|
|
53
|
+
validateNonce?: (nonce: Uint8Array) => boolean;
|
|
54
|
+
validateRecipient?: (recipient: string) => boolean;
|
|
55
|
+
validateMessage?: (message: string) => boolean;
|
|
56
|
+
getProfile?: (accountId: AccountId) => Promise<Profile | null>;
|
|
57
|
+
validateFunctionCallKey?: (args: {
|
|
58
|
+
accountId: AccountId;
|
|
59
|
+
publicKey: string;
|
|
60
|
+
contractId?: string;
|
|
61
|
+
}) => Promise<boolean>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const siwn = (options: SIWNPluginOptions) =>
|
|
65
|
+
({
|
|
66
|
+
id: "siwn",
|
|
67
|
+
schema,
|
|
68
|
+
endpoints: {
|
|
69
|
+
getSiwnNonce: createAuthEndpoint(
|
|
70
|
+
"/near/nonce",
|
|
71
|
+
{
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: NonceRequest,
|
|
74
|
+
},
|
|
75
|
+
async (ctx) => {
|
|
76
|
+
const { accountId } = ctx.body;
|
|
77
|
+
const network = getNetworkFromAccountId(accountId);
|
|
78
|
+
const nonce = options.getNonce ? await options.getNonce() : generateNonce();
|
|
79
|
+
|
|
80
|
+
// Store nonce as base64 string for database compatibility
|
|
81
|
+
const nonceString = bytesToBase64(nonce);
|
|
82
|
+
|
|
83
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
84
|
+
identifier: `siwn:${accountId}:${network}`,
|
|
85
|
+
value: nonceString!,
|
|
86
|
+
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return ctx.json(NonceResponse.parse({ nonce: nonceString }));
|
|
90
|
+
},
|
|
91
|
+
),
|
|
92
|
+
getSiwnProfile: createAuthEndpoint(
|
|
93
|
+
"/near/profile",
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
body: ProfileRequest,
|
|
97
|
+
use: [sessionMiddleware],
|
|
98
|
+
},
|
|
99
|
+
async (ctx) => {
|
|
100
|
+
const { accountId } = ctx.body;
|
|
101
|
+
let targetAccountId = accountId;
|
|
102
|
+
|
|
103
|
+
if (!targetAccountId) {
|
|
104
|
+
const session = ctx.context.session;
|
|
105
|
+
if (!session) {
|
|
106
|
+
throw new APIError("UNAUTHORIZED", {
|
|
107
|
+
message: "Session required when no accountId provided",
|
|
108
|
+
status: 401,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const nearAccount: NearAccount | null = await ctx.context.adapter.findOne({
|
|
113
|
+
model: "nearAccount",
|
|
114
|
+
where: [
|
|
115
|
+
{ field: "userId", operator: "eq", value: session.user.id },
|
|
116
|
+
{ field: "isPrimary", operator: "eq", value: true },
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!nearAccount) {
|
|
121
|
+
throw new APIError("NOT_FOUND", {
|
|
122
|
+
message: "No NEAR account found for user",
|
|
123
|
+
status: 404,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
targetAccountId = nearAccount.accountId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const profile = await (options.getProfile || defaultGetProfile)(targetAccountId);
|
|
131
|
+
return ctx.json(ProfileResponse.parse(profile));
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
verifySiwnMessage: createAuthEndpoint(
|
|
135
|
+
"/near/verify",
|
|
136
|
+
{
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: VerifyRequest.refine((data) => options.anonymous !== false || !!data.email, {
|
|
139
|
+
message: "Email is required when the anonymous plugin option is disabled.",
|
|
140
|
+
path: ["email"],
|
|
141
|
+
}),
|
|
142
|
+
requireRequest: true,
|
|
143
|
+
},
|
|
144
|
+
async (ctx) => {
|
|
145
|
+
const {
|
|
146
|
+
authToken,
|
|
147
|
+
accountId,
|
|
148
|
+
email,
|
|
149
|
+
} = ctx.body;
|
|
150
|
+
const network = getNetworkFromAccountId(accountId);
|
|
151
|
+
const isAnon = options.anonymous ?? true;
|
|
152
|
+
|
|
153
|
+
if (!isAnon && !email) {
|
|
154
|
+
throw new APIError("BAD_REQUEST", {
|
|
155
|
+
message: "Email is required when anonymous is disabled.",
|
|
156
|
+
status: 400,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const verification =
|
|
162
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
163
|
+
`siwn:${accountId}:${network}`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!verification || new Date() > verification.expiresAt) {
|
|
167
|
+
throw new APIError("UNAUTHORIZED", {
|
|
168
|
+
message: "Unauthorized: Invalid or expired nonce",
|
|
169
|
+
status: 401,
|
|
170
|
+
code: "UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build verify options using plugin configuration
|
|
175
|
+
const requireFullAccessKey = options.requireFullAccessKey ?? true;
|
|
176
|
+
const verifyOptions: VerifyOptions = {
|
|
177
|
+
requireFullAccessKey: requireFullAccessKey,
|
|
178
|
+
...(options.validateNonce
|
|
179
|
+
? { validateNonce: options.validateNonce }
|
|
180
|
+
: { nonceMaxAge: 15 * 60 * 1000 }),
|
|
181
|
+
...(options.validateRecipient
|
|
182
|
+
? { validateRecipient: options.validateRecipient }
|
|
183
|
+
: { expectedRecipient: options.recipient }),
|
|
184
|
+
...(options.validateMessage ? { validateMessage: options.validateMessage } : {}),
|
|
185
|
+
} as VerifyOptions;
|
|
186
|
+
|
|
187
|
+
// Verify the signature using near-sign-verify
|
|
188
|
+
const result: VerificationResult = await verify(authToken, verifyOptions);
|
|
189
|
+
|
|
190
|
+
if (result.accountId !== accountId) {
|
|
191
|
+
throw new APIError("UNAUTHORIZED", {
|
|
192
|
+
message: "Unauthorized: Account ID mismatch",
|
|
193
|
+
status: 401,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!options.requireFullAccessKey && options.validateFunctionCallKey) {
|
|
198
|
+
const isValidFunctionKey = await options.validateFunctionCallKey({
|
|
199
|
+
accountId: result.accountId,
|
|
200
|
+
publicKey: result.publicKey,
|
|
201
|
+
}); // we can validate against an access control contract
|
|
202
|
+
|
|
203
|
+
if (!isValidFunctionKey) {
|
|
204
|
+
throw new APIError("UNAUTHORIZED", {
|
|
205
|
+
message: "Unauthorized: Invalid function call access key",
|
|
206
|
+
status: 401,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await ctx.context.internalAdapter.deleteVerificationValue(
|
|
212
|
+
verification.id,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
let user: User | null = null;
|
|
216
|
+
|
|
217
|
+
const existingNearAccount: NearAccount | null =
|
|
218
|
+
await ctx.context.adapter.findOne({
|
|
219
|
+
model: "nearAccount",
|
|
220
|
+
where: [
|
|
221
|
+
{ field: "accountId", operator: "eq", value: accountId },
|
|
222
|
+
{ field: "network", operator: "eq", value: network },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (existingNearAccount) {
|
|
227
|
+
user = await ctx.context.adapter.findOne({
|
|
228
|
+
model: "user",
|
|
229
|
+
where: [
|
|
230
|
+
{
|
|
231
|
+
field: "id",
|
|
232
|
+
operator: "eq",
|
|
233
|
+
value: existingNearAccount.userId,
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
} else {
|
|
238
|
+
const anyNearAccount: NearAccount | null =
|
|
239
|
+
await ctx.context.adapter.findOne({
|
|
240
|
+
model: "nearAccount",
|
|
241
|
+
where: [
|
|
242
|
+
{ field: "accountId", operator: "eq", value: accountId },
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (anyNearAccount) {
|
|
247
|
+
user = await ctx.context.adapter.findOne({
|
|
248
|
+
model: "user",
|
|
249
|
+
where: [
|
|
250
|
+
{
|
|
251
|
+
field: "id",
|
|
252
|
+
operator: "eq",
|
|
253
|
+
value: anyNearAccount.userId,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!user) {
|
|
261
|
+
const domain =
|
|
262
|
+
options.emailDomainName ?? getOrigin(ctx.context.baseURL);
|
|
263
|
+
const userEmail =
|
|
264
|
+
!isAnon && email ? email : `${accountId}@${domain}`;
|
|
265
|
+
|
|
266
|
+
const profile = await (options.getProfile || defaultGetProfile)(accountId);
|
|
267
|
+
|
|
268
|
+
user = await ctx.context.internalAdapter.createUser({
|
|
269
|
+
name: profile?.name ?? accountId,
|
|
270
|
+
email: userEmail,
|
|
271
|
+
image: profile?.image ? getImageUrl(profile.image) : "",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await ctx.context.adapter.create({
|
|
275
|
+
model: "nearAccount",
|
|
276
|
+
data: {
|
|
277
|
+
userId: user.id,
|
|
278
|
+
accountId,
|
|
279
|
+
network,
|
|
280
|
+
publicKey: result.publicKey,
|
|
281
|
+
isPrimary: true,
|
|
282
|
+
createdAt: new Date(),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await ctx.context.internalAdapter.createAccount({
|
|
287
|
+
userId: user.id,
|
|
288
|
+
providerId: "siwn",
|
|
289
|
+
accountId: `${accountId}:${network}`,
|
|
290
|
+
createdAt: new Date(),
|
|
291
|
+
updatedAt: new Date(),
|
|
292
|
+
});
|
|
293
|
+
} else {
|
|
294
|
+
if (!existingNearAccount) {
|
|
295
|
+
await ctx.context.adapter.create({
|
|
296
|
+
model: "nearAccount",
|
|
297
|
+
data: {
|
|
298
|
+
userId: user.id,
|
|
299
|
+
accountId,
|
|
300
|
+
network,
|
|
301
|
+
publicKey: result.publicKey,
|
|
302
|
+
isPrimary: false,
|
|
303
|
+
createdAt: new Date(),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await ctx.context.internalAdapter.createAccount({
|
|
308
|
+
userId: user.id,
|
|
309
|
+
providerId: "siwn",
|
|
310
|
+
accountId: `${accountId}:${network}`,
|
|
311
|
+
createdAt: new Date(),
|
|
312
|
+
updatedAt: new Date(),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const session = await ctx.context.internalAdapter.createSession(
|
|
318
|
+
user.id,
|
|
319
|
+
ctx,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (!session) {
|
|
323
|
+
throw new APIError("INTERNAL_SERVER_ERROR", {
|
|
324
|
+
message: "Internal Server Error",
|
|
325
|
+
status: 500,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
await setSessionCookie(ctx, { session, user });
|
|
330
|
+
|
|
331
|
+
return ctx.json(VerifyResponse.parse({
|
|
332
|
+
token: session.token,
|
|
333
|
+
success: true,
|
|
334
|
+
user: {
|
|
335
|
+
id: user.id,
|
|
336
|
+
accountId,
|
|
337
|
+
network,
|
|
338
|
+
},
|
|
339
|
+
}));
|
|
340
|
+
} catch (error: unknown) {
|
|
341
|
+
if (error instanceof APIError) throw error;
|
|
342
|
+
throw new APIError("UNAUTHORIZED", {
|
|
343
|
+
message: "Something went wrong. Please try again later.",
|
|
344
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
345
|
+
status: 401,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
),
|
|
350
|
+
},
|
|
351
|
+
}) satisfies BetterAuthPlugin;
|
package/src/near.test.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// import { describe, expect } from "vitest";
|
|
2
|
+
// import { getTestInstance } from "better-auth/test-utils/test-instance";
|
|
3
|
+
// import { siwn } from "./index";
|
|
4
|
+
// import { siwnClient } from "./client";
|
|
5
|
+
|
|
6
|
+
// describe("siwn", async (it) => {
|
|
7
|
+
// const accountId = "test.near";
|
|
8
|
+
// const testnetAccountId = "test.testnet";
|
|
9
|
+
// const domain = "example.com";
|
|
10
|
+
|
|
11
|
+
// it("should generate a valid nonce for a valid NEAR account ID", async () => {
|
|
12
|
+
// const { client } = await getTestInstance(
|
|
13
|
+
// {
|
|
14
|
+
// plugins: [
|
|
15
|
+
// siwn({
|
|
16
|
+
// domain,
|
|
17
|
+
// async getNonce() {
|
|
18
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
19
|
+
// },
|
|
20
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
21
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
22
|
+
// },
|
|
23
|
+
// }),
|
|
24
|
+
// ],
|
|
25
|
+
// },
|
|
26
|
+
// {
|
|
27
|
+
// clientOptions: {
|
|
28
|
+
// plugins: [siwnClient()],
|
|
29
|
+
// },
|
|
30
|
+
// },
|
|
31
|
+
// );
|
|
32
|
+
// const { data } = await client.near.nonce({ accountId });
|
|
33
|
+
// expect(typeof data?.nonce).toBe("string");
|
|
34
|
+
// expect(data?.nonce).toMatch(/^[a-zA-Z0-9]{17}$/);
|
|
35
|
+
// });
|
|
36
|
+
|
|
37
|
+
// it("should detect mainnet network for regular account IDs", async () => {
|
|
38
|
+
// const { client } = await getTestInstance(
|
|
39
|
+
// {
|
|
40
|
+
// plugins: [
|
|
41
|
+
// siwn({
|
|
42
|
+
// domain,
|
|
43
|
+
// async getNonce() {
|
|
44
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
45
|
+
// },
|
|
46
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
47
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
48
|
+
// },
|
|
49
|
+
// }),
|
|
50
|
+
// ],
|
|
51
|
+
// },
|
|
52
|
+
// {
|
|
53
|
+
// clientOptions: {
|
|
54
|
+
// plugins: [siwnClient()],
|
|
55
|
+
// },
|
|
56
|
+
// },
|
|
57
|
+
// );
|
|
58
|
+
// const { data } = await client.near.nonce({ accountId: "user.near" });
|
|
59
|
+
// expect(typeof data?.nonce).toBe("string");
|
|
60
|
+
// });
|
|
61
|
+
|
|
62
|
+
// it("should detect testnet network for .testnet account IDs", async () => {
|
|
63
|
+
// const { client } = await getTestInstance(
|
|
64
|
+
// {
|
|
65
|
+
// plugins: [
|
|
66
|
+
// siwn({
|
|
67
|
+
// domain,
|
|
68
|
+
// async getNonce() {
|
|
69
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
70
|
+
// },
|
|
71
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
72
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
73
|
+
// },
|
|
74
|
+
// }),
|
|
75
|
+
// ],
|
|
76
|
+
// },
|
|
77
|
+
// {
|
|
78
|
+
// clientOptions: {
|
|
79
|
+
// plugins: [siwnClient()],
|
|
80
|
+
// },
|
|
81
|
+
// },
|
|
82
|
+
// );
|
|
83
|
+
// const { data } = await client.near.nonce({ accountId: testnetAccountId });
|
|
84
|
+
// expect(typeof data?.nonce).toBe("string");
|
|
85
|
+
// });
|
|
86
|
+
|
|
87
|
+
// it("should reject verification if nonce is missing", async () => {
|
|
88
|
+
// const { client } = await getTestInstance(
|
|
89
|
+
// {
|
|
90
|
+
// plugins: [
|
|
91
|
+
// siwn({
|
|
92
|
+
// domain,
|
|
93
|
+
// async getNonce() {
|
|
94
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
95
|
+
// },
|
|
96
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
97
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
98
|
+
// },
|
|
99
|
+
// }),
|
|
100
|
+
// ],
|
|
101
|
+
// },
|
|
102
|
+
// {
|
|
103
|
+
// clientOptions: {
|
|
104
|
+
// plugins: [siwnClient()],
|
|
105
|
+
// },
|
|
106
|
+
// },
|
|
107
|
+
// );
|
|
108
|
+
// const { error } = await client.near.verify({
|
|
109
|
+
// authToken: "valid_token",
|
|
110
|
+
// accountId,
|
|
111
|
+
// });
|
|
112
|
+
|
|
113
|
+
// expect(error).toBeDefined();
|
|
114
|
+
// expect(error?.status).toBe(401);
|
|
115
|
+
// expect(error?.code).toBe("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE");
|
|
116
|
+
// expect(error?.message).toMatch(/nonce/i);
|
|
117
|
+
// });
|
|
118
|
+
|
|
119
|
+
// it("should reject invalid account ID format", async () => {
|
|
120
|
+
// const { client } = await getTestInstance(
|
|
121
|
+
// {
|
|
122
|
+
// plugins: [
|
|
123
|
+
// siwn({
|
|
124
|
+
// domain,
|
|
125
|
+
// async getNonce() {
|
|
126
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
127
|
+
// },
|
|
128
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
129
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
130
|
+
// },
|
|
131
|
+
// }),
|
|
132
|
+
// ],
|
|
133
|
+
// },
|
|
134
|
+
// {
|
|
135
|
+
// clientOptions: {
|
|
136
|
+
// plugins: [siwnClient()],
|
|
137
|
+
// },
|
|
138
|
+
// },
|
|
139
|
+
// );
|
|
140
|
+
// const { error } = await client.near.nonce({ accountId: "invalid-account" });
|
|
141
|
+
// expect(error).toBeDefined();
|
|
142
|
+
// expect(error?.status).toBe(400);
|
|
143
|
+
// expect(error?.message).toBe("Invalid body parameters");
|
|
144
|
+
// });
|
|
145
|
+
|
|
146
|
+
// it("should allow function call keys when requireFullAccessKey is false", async () => {
|
|
147
|
+
// const { client } = await getTestInstance(
|
|
148
|
+
// {
|
|
149
|
+
// plugins: [
|
|
150
|
+
// siwn({
|
|
151
|
+
// domain,
|
|
152
|
+
// requireFullAccessKey: false,
|
|
153
|
+
// async getNonce() {
|
|
154
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
155
|
+
// },
|
|
156
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
157
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
158
|
+
// },
|
|
159
|
+
// async validateFunctionCallKey({ accountId, publicKey }) {
|
|
160
|
+
// return accountId === "test.near" && publicKey !== "";
|
|
161
|
+
// },
|
|
162
|
+
// }),
|
|
163
|
+
// ],
|
|
164
|
+
// },
|
|
165
|
+
// {
|
|
166
|
+
// clientOptions: {
|
|
167
|
+
// plugins: [siwnClient()],
|
|
168
|
+
// },
|
|
169
|
+
// },
|
|
170
|
+
// );
|
|
171
|
+
|
|
172
|
+
// await client.near.nonce({ accountId });
|
|
173
|
+
// const { data, error } = await client.near.verify({
|
|
174
|
+
// authToken: "valid_token",
|
|
175
|
+
// accountId,
|
|
176
|
+
// });
|
|
177
|
+
// expect(error).toBeNull();
|
|
178
|
+
// expect(data?.success).toBe(true);
|
|
179
|
+
// });
|
|
180
|
+
|
|
181
|
+
// it("should get profile for current user when no accountId provided", async () => {
|
|
182
|
+
// const { client } = await getTestInstance(
|
|
183
|
+
// {
|
|
184
|
+
// plugins: [
|
|
185
|
+
// siwn({
|
|
186
|
+
// domain,
|
|
187
|
+
// async getNonce() {
|
|
188
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
189
|
+
// },
|
|
190
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
191
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
192
|
+
// },
|
|
193
|
+
// async getProfile(accountId) {
|
|
194
|
+
// return {
|
|
195
|
+
// name: `Profile for ${accountId}`,
|
|
196
|
+
// description: "Test profile",
|
|
197
|
+
// };
|
|
198
|
+
// },
|
|
199
|
+
// }),
|
|
200
|
+
// ],
|
|
201
|
+
// },
|
|
202
|
+
// {
|
|
203
|
+
// clientOptions: {
|
|
204
|
+
// plugins: [siwnClient()],
|
|
205
|
+
// },
|
|
206
|
+
// },
|
|
207
|
+
// );
|
|
208
|
+
|
|
209
|
+
// await client.near.nonce({ accountId });
|
|
210
|
+
// await client.near.verify({
|
|
211
|
+
// authToken: "valid_token",
|
|
212
|
+
// accountId,
|
|
213
|
+
// });
|
|
214
|
+
|
|
215
|
+
// const { data } = await client.near.getProfile();
|
|
216
|
+
// expect(data?.profile?.name).toBe(`Profile for ${accountId}`);
|
|
217
|
+
// });
|
|
218
|
+
|
|
219
|
+
// it("should get profile for specific accountId when provided", async () => {
|
|
220
|
+
// const { client } = await getTestInstance(
|
|
221
|
+
// {
|
|
222
|
+
// plugins: [
|
|
223
|
+
// siwn({
|
|
224
|
+
// domain,
|
|
225
|
+
// async getNonce() {
|
|
226
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
227
|
+
// },
|
|
228
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
229
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
230
|
+
// },
|
|
231
|
+
// async getProfile(accountId) {
|
|
232
|
+
// return {
|
|
233
|
+
// name: `Profile for ${accountId}`,
|
|
234
|
+
// description: "Test profile",
|
|
235
|
+
// };
|
|
236
|
+
// },
|
|
237
|
+
// }),
|
|
238
|
+
// ],
|
|
239
|
+
// },
|
|
240
|
+
// {
|
|
241
|
+
// clientOptions: {
|
|
242
|
+
// plugins: [siwnClient()],
|
|
243
|
+
// },
|
|
244
|
+
// },
|
|
245
|
+
// );
|
|
246
|
+
|
|
247
|
+
// const targetAccount = "alice.near";
|
|
248
|
+
// const { data } = await client.near.getProfile(targetAccount);
|
|
249
|
+
// expect(data?.profile?.name).toBe(`Profile for ${targetAccount}`);
|
|
250
|
+
// });
|
|
251
|
+
|
|
252
|
+
// it("should validate various NEAR account ID formats", async () => {
|
|
253
|
+
// const { client } = await getTestInstance(
|
|
254
|
+
// {
|
|
255
|
+
// plugins: [
|
|
256
|
+
// siwn({
|
|
257
|
+
// domain,
|
|
258
|
+
// async getNonce() {
|
|
259
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
260
|
+
// },
|
|
261
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
262
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
263
|
+
// },
|
|
264
|
+
// }),
|
|
265
|
+
// ],
|
|
266
|
+
// },
|
|
267
|
+
// {
|
|
268
|
+
// clientOptions: { plugins: [siwnClient()] },
|
|
269
|
+
// },
|
|
270
|
+
// );
|
|
271
|
+
|
|
272
|
+
// const validAccountIds = [
|
|
273
|
+
// "user.near",
|
|
274
|
+
// "test.testnet",
|
|
275
|
+
// "alice.tg",
|
|
276
|
+
// "bob.kaito",
|
|
277
|
+
// "sub.account.near",
|
|
278
|
+
// "a1b2c3.near",
|
|
279
|
+
// "user-name.near",
|
|
280
|
+
// "user_name.near",
|
|
281
|
+
// "deep.sub.account.near",
|
|
282
|
+
// "app.myproject.near",
|
|
283
|
+
// ];
|
|
284
|
+
|
|
285
|
+
// for (const accountId of validAccountIds) {
|
|
286
|
+
// const { data, error } = await client.near.nonce({ accountId });
|
|
287
|
+
// expect(error).toBeNull();
|
|
288
|
+
// expect(typeof data?.nonce).toBe("string");
|
|
289
|
+
// }
|
|
290
|
+
// });
|
|
291
|
+
|
|
292
|
+
// it("should reject invalid NEAR account ID formats", async () => {
|
|
293
|
+
// const { client } = await getTestInstance(
|
|
294
|
+
// {
|
|
295
|
+
// plugins: [
|
|
296
|
+
// siwn({
|
|
297
|
+
// domain,
|
|
298
|
+
// async getNonce() {
|
|
299
|
+
// return "A1b2C3d4E5f6G7h8J";
|
|
300
|
+
// },
|
|
301
|
+
// async verifyMessage({ authToken, expectedRecipient, accountId }) {
|
|
302
|
+
// return authToken === "valid_token" && expectedRecipient === domain;
|
|
303
|
+
// },
|
|
304
|
+
// }),
|
|
305
|
+
// ],
|
|
306
|
+
// },
|
|
307
|
+
// {
|
|
308
|
+
// clientOptions: { plugins: [siwnClient()] },
|
|
309
|
+
// },
|
|
310
|
+
// );
|
|
311
|
+
|
|
312
|
+
// const invalidAccountIds = [
|
|
313
|
+
// "",
|
|
314
|
+
// "a",
|
|
315
|
+
// "A",
|
|
316
|
+
// "user.NEAR",
|
|
317
|
+
// "user..near",
|
|
318
|
+
// ".user.near",
|
|
319
|
+
// "user.near.",
|
|
320
|
+
// "user@near",
|
|
321
|
+
// "user near",
|
|
322
|
+
// "x".repeat(65),
|
|
323
|
+
// ];
|
|
324
|
+
|
|
325
|
+
// for (const accountId of invalidAccountIds) {
|
|
326
|
+
// const { error } = await client.near.nonce({ accountId });
|
|
327
|
+
// expect(error).toBeDefined();
|
|
328
|
+
// expect(error?.status).toBe(400);
|
|
329
|
+
// }
|
|
330
|
+
// });
|
|
331
|
+
// });
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AccountId, Profile, SocialImage } from "./types";
|
|
2
|
+
|
|
3
|
+
const FALLBACK_URL =
|
|
4
|
+
"https://ipfs.near.social/ipfs/bafkreidn5fb2oygegqaldx7ycdmhu4owcrmoxd7ekbzfmeakkobz2ja7qy";
|
|
5
|
+
|
|
6
|
+
function getNetworkFromAccountId(accountId: string): "mainnet" | "testnet" {
|
|
7
|
+
return accountId.endsWith('.testnet') ? 'testnet' : 'mainnet';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getImageUrl(
|
|
11
|
+
image: SocialImage | undefined,
|
|
12
|
+
fallback?: string,
|
|
13
|
+
): string {
|
|
14
|
+
if (image?.url) return image.url;
|
|
15
|
+
if (image?.ipfs_cid) return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`;
|
|
16
|
+
return fallback || FALLBACK_URL;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SocialApiResponse {
|
|
20
|
+
[accountId: string]: {
|
|
21
|
+
profile?: Profile;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function defaultGetProfile(accountId: AccountId): Promise<Profile | null> {
|
|
26
|
+
const network = getNetworkFromAccountId(accountId);
|
|
27
|
+
const apiBase = {
|
|
28
|
+
mainnet: "https://api.near.social",
|
|
29
|
+
testnet: "https://test.api.near.social",
|
|
30
|
+
}[network];
|
|
31
|
+
|
|
32
|
+
const keys = [`${accountId}/profile/**`];
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(`${apiBase}/get`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ keys })
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const data = await response.json() as SocialApiResponse;
|
|
46
|
+
const profile: Profile | undefined = data?.[accountId]?.profile;
|
|
47
|
+
|
|
48
|
+
if (profile) {
|
|
49
|
+
return {
|
|
50
|
+
name: profile.name,
|
|
51
|
+
description: profile.description,
|
|
52
|
+
image: profile.image,
|
|
53
|
+
backgroundImage: profile.backgroundImage,
|
|
54
|
+
linktree: profile.linktree
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { defaultGetProfile, getImageUrl, getNetworkFromAccountId };
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AuthPluginSchema } from "better-auth/types";
|
|
2
|
+
|
|
3
|
+
export const schema = {
|
|
4
|
+
nearAccount: {
|
|
5
|
+
fields: {
|
|
6
|
+
userId: {
|
|
7
|
+
type: "string",
|
|
8
|
+
references: {
|
|
9
|
+
model: "user",
|
|
10
|
+
field: "id",
|
|
11
|
+
},
|
|
12
|
+
required: true,
|
|
13
|
+
},
|
|
14
|
+
accountId: {
|
|
15
|
+
type: "string",
|
|
16
|
+
required: true,
|
|
17
|
+
},
|
|
18
|
+
network: {
|
|
19
|
+
type: "string",
|
|
20
|
+
required: true,
|
|
21
|
+
},
|
|
22
|
+
publicKey: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
isPrimary: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
defaultValue: false,
|
|
29
|
+
},
|
|
30
|
+
createdAt: {
|
|
31
|
+
type: "date",
|
|
32
|
+
required: true,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
} satisfies AuthPluginSchema;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const accountIdSchema = z.string()
|
|
4
|
+
.min(2)
|
|
5
|
+
.max(64)
|
|
6
|
+
.regex(/^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/, "Invalid NEAR account ID format");
|
|
7
|
+
|
|
8
|
+
export type AccountId = z.infer<typeof accountIdSchema>;
|
|
9
|
+
|
|
10
|
+
export interface NearAccount {
|
|
11
|
+
id: string;
|
|
12
|
+
userId: string;
|
|
13
|
+
accountId: string;
|
|
14
|
+
network: "mainnet" | "testnet";
|
|
15
|
+
publicKey: string;
|
|
16
|
+
isPrimary: boolean;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const socialImageSchema = z.object({
|
|
21
|
+
url: z.string().optional(),
|
|
22
|
+
ipfs_cid: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const profileSchema = z.object({
|
|
26
|
+
name: z.string().optional(),
|
|
27
|
+
description: z.string().optional(),
|
|
28
|
+
image: socialImageSchema.optional(),
|
|
29
|
+
backgroundImage: socialImageSchema.optional(),
|
|
30
|
+
linktree: z.record(z.string(), z.string()).optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type SocialImage = z.infer<typeof socialImageSchema>;
|
|
34
|
+
export type Profile = z.infer<typeof profileSchema>;
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
export const NonceRequest = z.object({ accountId: accountIdSchema });
|
|
38
|
+
export const VerifyRequest = z.object({
|
|
39
|
+
authToken: z.string().min(1),
|
|
40
|
+
accountId: accountIdSchema,
|
|
41
|
+
email: z.email().optional(),
|
|
42
|
+
});
|
|
43
|
+
export const ProfileRequest = z.object({
|
|
44
|
+
accountId: accountIdSchema.optional(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const NonceResponse = z.object({ nonce: z.string() }); // Base64 string
|
|
48
|
+
export const VerifyResponse = z.object({
|
|
49
|
+
token: z.string(),
|
|
50
|
+
success: z.literal(true),
|
|
51
|
+
user: z.object({
|
|
52
|
+
id: z.string(),
|
|
53
|
+
accountId: accountIdSchema,
|
|
54
|
+
network: z.union([z.literal("mainnet"), z.literal("testnet")]),
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
export const ProfileResponse = profileSchema.nullable();
|
|
58
|
+
|
|
59
|
+
export type NonceRequestT = z.infer<typeof NonceRequest>;
|
|
60
|
+
export type NonceResponseT = z.infer<typeof NonceResponse>;
|
|
61
|
+
export type VerifyRequestT = z.infer<typeof VerifyRequest>;
|
|
62
|
+
export type VerifyResponseT = z.infer<typeof VerifyResponse>;
|
|
63
|
+
export type ProfileRequestT = z.infer<typeof ProfileRequest>;
|
|
64
|
+
export type ProfileResponseT = z.infer<typeof ProfileResponse>;
|