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 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;
@@ -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>;