better-near-auth 0.1.13 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  </div>
15
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 session management, secure defaults, and automatic profile integration.
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) to provide a complete drop-in solution with session management, secure defaults, and automatic profile integration.
17
17
 
18
18
  ## Installation
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-near-auth",
3
- "version": "0.1.13",
3
+ "version": "0.2.1",
4
4
  "description": "Sign in with NEAR (SIWN) plugin for Better Auth",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
@@ -44,20 +44,19 @@
44
44
  "homepage": "https://github.com/elliotBraem/better-near-auth#readme",
45
45
  "peerDependencies": {
46
46
  "better-auth": "^1.0.0",
47
- "typescript": "^5.0.0"
47
+ "typescript": "^5.7.0"
48
48
  },
49
49
  "dependencies": {
50
- "@better-auth/utils": "^0.2.6",
51
- "fastintear": "^0.3.6",
50
+ "fastintear": "^0.3.7",
52
51
  "nanostores": "^1.0.1",
53
- "near-sign-verify": "^0.4.3",
54
- "zod": "^4.0.17"
52
+ "near-sign-verify": "^0.4.4",
53
+ "zod": "^4.1.12"
55
54
  },
56
55
  "devDependencies": {
57
56
  "@types/bun": "latest",
58
- "@types/node": "^24.3.0",
59
- "better-auth": "^1.3.6",
60
- "typescript": "^5.9.2",
57
+ "@types/node": "^24.8.1",
58
+ "better-auth": "^1.3.34",
59
+ "typescript": "^5.9.3",
61
60
  "vitest": "^3.2.4"
62
61
  },
63
62
  "files": [
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { base64ToBytes } from "fastintear/utils";
2
1
  import type { BetterAuthClientPlugin, BetterFetch, BetterFetchOption, BetterFetchResponse } from "better-auth/client";
3
2
  import { createNearClient } from "fastintear";
3
+ import { base64ToBytes } from "fastintear/utils";
4
4
  import { atom } from "nanostores";
5
5
  import { parseAuthToken, sign } from "near-sign-verify";
6
6
  import type { siwn } from ".";
@@ -34,6 +34,9 @@ export interface SIWNClientActions {
34
34
  getAccountId: () => string | null;
35
35
  getState: () => { accountId: string | null; publicKey: string | null; networkId: string } | null;
36
36
  disconnect: () => Promise<void>;
37
+ link: (params: { recipient: string }, callbacks?: AuthCallbacks) => Promise<void>;
38
+ unlink: (params: { accountId: string; network?: "mainnet" | "testnet" }) => Promise<BetterFetchResponse<{ success: boolean; message: string }>>;
39
+ listAccounts: () => Promise<BetterFetchResponse<{ accounts: any[] }>>;
37
40
  };
38
41
  requestSignIn: {
39
42
  near: (params: { recipient: string }, callbacks?: AuthCallbacks) => Promise<void>;
@@ -71,12 +74,12 @@ export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
71
74
  return {
72
75
  id: "siwn",
73
76
  $InferServerPlugin: {} as ReturnType<typeof siwn>,
74
-
77
+
75
78
  getAtoms: ($fetch) => ({
76
79
  nearState,
77
80
  cachedNonce,
78
81
  }),
79
-
82
+
80
83
  getActions: ($fetch): SIWNClientActions => {
81
84
  const nearClient = createNearClient({
82
85
  networkId: config.networkId || "mainnet",
@@ -128,6 +131,95 @@ export const siwnClient = (config: SIWNClientConfig): SIWNClientPlugin => {
128
131
  clearNonce();
129
132
  nearState.set(null);
130
133
  },
134
+ link: async (
135
+ params: { recipient: string },
136
+ callbacks?: AuthCallbacks
137
+ ): Promise<void> => {
138
+ try {
139
+ const { recipient } = params;
140
+
141
+ if (!nearClient) {
142
+ const error = new Error("NEAR client not available") as Error & { code?: string };
143
+ error.code = "SIGNER_NOT_AVAILABLE";
144
+ throw error;
145
+ }
146
+
147
+ const accountId = nearClient.accountId();
148
+ if (!accountId) {
149
+ const error = new Error("Wallet not connected. Please connect your wallet first.") as Error & { code?: string };
150
+ error.code = "WALLET_NOT_CONNECTED";
151
+ throw error;
152
+ }
153
+
154
+ // Get nonce first
155
+ const state = nearState.get();
156
+ const nonceRequest: NonceRequestT = {
157
+ accountId,
158
+ publicKey: state?.publicKey || "",
159
+ networkId: (state?.networkId || "mainnet") as "mainnet" | "testnet"
160
+ };
161
+
162
+ const nonceResponse: BetterFetchResponse<NonceResponseT> = await $fetch("/near/nonce", {
163
+ method: "POST",
164
+ body: nonceRequest
165
+ });
166
+
167
+ if (nonceResponse.error) {
168
+ throw new Error(nonceResponse.error.message || "Failed to get nonce");
169
+ }
170
+
171
+ const nonce = nonceResponse?.data?.nonce;
172
+ if (!nonce) {
173
+ throw new Error("No nonce received from server");
174
+ }
175
+
176
+ // Create the sign-in message
177
+ const message = `Sign in to ${recipient}\n\nAccount ID: ${accountId}\nNonce: ${nonce}`;
178
+ const nonceBytes = base64ToBytes(nonce);
179
+
180
+ // Sign the message
181
+ const authToken = await sign(message, {
182
+ signer: nearClient,
183
+ recipient,
184
+ nonce: nonceBytes,
185
+ });
186
+
187
+ // Link the account (instead of verify)
188
+ const linkResponse: BetterFetchResponse<any> = await $fetch("/near/link-account", {
189
+ method: "POST",
190
+ body: {
191
+ authToken,
192
+ accountId,
193
+ }
194
+ });
195
+
196
+ if (linkResponse.error) {
197
+ throw new Error(linkResponse.error.message || "Failed to link NEAR account");
198
+ }
199
+
200
+ if (!linkResponse?.data?.success) {
201
+ throw new Error("Account linking failed");
202
+ }
203
+
204
+ callbacks?.onSuccess?.();
205
+ } catch (error) {
206
+ const err = error instanceof Error ? error : new Error(String(error));
207
+ callbacks?.onError?.(err);
208
+ }
209
+ },
210
+ unlink: async (
211
+ params: { accountId: string; network?: "mainnet" | "testnet" },
212
+ fetchOptions?: BetterFetchOption
213
+ ): Promise<BetterFetchResponse<{ success: boolean; message: string }>> => {
214
+ return await $fetch("/near/unlink-account", {
215
+ method: "POST",
216
+ body: params,
217
+ ...fetchOptions
218
+ });
219
+ },
220
+ listAccounts: async (): Promise<BetterFetchResponse<{ accounts: any[] }>> => {
221
+ return await $fetch("/near/list-accounts", { method: "GET" });
222
+ },
131
223
  },
132
224
  requestSignIn: {
133
225
  near: async (
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { bytesToBase64 } from "fastintear/utils";
2
1
  import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
3
2
  import { setSessionCookie } from "better-auth/cookies";
4
- import type { BetterAuthPlugin, User } from "better-auth/types";
5
- import { generateNonce, parseAuthToken, verify, type VerificationResult, type VerifyOptions } from "near-sign-verify";
3
+ import type { Account, BetterAuthPlugin, User } from "better-auth/types";
4
+ import { bytesToBase64 } from "fastintear/utils";
5
+ import { generateNonce, verify, type VerificationResult, type VerifyOptions } from "near-sign-verify";
6
+ import z from "zod";
6
7
  import { defaultGetProfile, getImageUrl, getNetworkFromAccountId } from "./profile";
7
8
  import { schema } from "./schema";
8
9
  import type {
@@ -83,7 +84,7 @@ export const siwn = (options: SIWNPluginOptions) =>
83
84
  { field: "isPrimary", operator: "eq", value: true },
84
85
  ],
85
86
  });
86
-
87
+
87
88
  if (nearAccount) {
88
89
  // Add NEAR account to session response
89
90
  ctx.context.session = {
@@ -95,13 +96,274 @@ export const siwn = (options: SIWNPluginOptions) =>
95
96
  };
96
97
  }
97
98
  }
98
-
99
+
99
100
  return { context: ctx };
100
101
  }),
101
102
  },
102
103
  ],
103
104
  },
104
105
  endpoints: {
106
+ linkNearAccount: createAuthEndpoint(
107
+ "/near/link-account",
108
+ {
109
+ method: "POST",
110
+ body: VerifyRequest.refine((data) => options.anonymous !== false || !!data.email, {
111
+ message: "Email is required when the anonymous plugin option is disabled.",
112
+ path: ["email"],
113
+ }),
114
+ use: [sessionMiddleware], // Requires active session
115
+ requireRequest: true,
116
+ },
117
+ async (ctx) => {
118
+ const { authToken, accountId, email } = ctx.body;
119
+ const network = getNetworkFromAccountId(accountId);
120
+ const session = ctx.context.session;
121
+
122
+ if (!session) {
123
+ throw new APIError("UNAUTHORIZED", {
124
+ message: "Must be logged in to link NEAR account",
125
+ status: 401,
126
+ });
127
+ }
128
+
129
+ try {
130
+ const verification = await ctx.context.internalAdapter.findVerificationValue(
131
+ `siwn:${accountId}:${network}`,
132
+ );
133
+
134
+ if (!verification || new Date() > verification.expiresAt) {
135
+ throw new APIError("UNAUTHORIZED", {
136
+ message: "Unauthorized: Invalid or expired nonce",
137
+ status: 401,
138
+ });
139
+ }
140
+
141
+ // Build verify options using plugin configuration
142
+ const requireFullAccessKey = options.requireFullAccessKey ?? true;
143
+ const verifyOptions: VerifyOptions = {
144
+ requireFullAccessKey: requireFullAccessKey,
145
+ ...(options.validateNonce
146
+ ? { validateNonce: options.validateNonce }
147
+ : { nonceMaxAge: 15 * 60 * 1000 }),
148
+ ...(options.validateRecipient
149
+ ? { validateRecipient: options.validateRecipient }
150
+ : { expectedRecipient: options.recipient }),
151
+ ...(options.validateMessage ? { validateMessage: options.validateMessage } : {}),
152
+ } as VerifyOptions;
153
+
154
+ // Verify the signature using near-sign-verify
155
+ const result: VerificationResult = await verify(authToken, verifyOptions);
156
+
157
+ if (result.accountId !== accountId) {
158
+ throw new APIError("UNAUTHORIZED", {
159
+ message: "Unauthorized: Account ID mismatch",
160
+ status: 401,
161
+ });
162
+ }
163
+
164
+ if (!options.requireFullAccessKey && options.validateLimitedAccessKey) {
165
+ const isValidKey = await options.validateLimitedAccessKey({
166
+ accountId: result.accountId,
167
+ publicKey: result.publicKey,
168
+ recipient: options.recipient
169
+ });
170
+
171
+ if (!isValidKey) {
172
+ throw new APIError("UNAUTHORIZED", {
173
+ message: "Unauthorized: Invalid function call access key",
174
+ status: 401,
175
+ });
176
+ }
177
+ }
178
+
179
+ await ctx.context.internalAdapter.deleteVerificationValue(verification.id);
180
+
181
+ // Check if this NEAR account is already linked
182
+ const existingNearAccount: NearAccount | null = await ctx.context.adapter.findOne({
183
+ model: "nearAccount",
184
+ where: [
185
+ { field: "accountId", operator: "eq", value: accountId },
186
+ { field: "network", operator: "eq", value: network },
187
+ ],
188
+ });
189
+
190
+ if (existingNearAccount) {
191
+ throw new APIError("BAD_REQUEST", {
192
+ message: "This NEAR account is already linked to another user",
193
+ status: 400,
194
+ });
195
+ }
196
+
197
+ // Check if user already has a primary NEAR account
198
+ const existingPrimaryAccount: NearAccount | null = await ctx.context.adapter.findOne({
199
+ model: "nearAccount",
200
+ where: [
201
+ { field: "userId", operator: "eq", value: session.user.id },
202
+ { field: "isPrimary", operator: "eq", value: true },
203
+ ],
204
+ });
205
+
206
+ // Link the NEAR account to the current user
207
+ await ctx.context.adapter.create({
208
+ model: "nearAccount",
209
+ data: {
210
+ userId: session.user.id,
211
+ accountId,
212
+ network,
213
+ publicKey: result.publicKey,
214
+ isPrimary: !existingPrimaryAccount, // First NEAR account becomes primary
215
+ createdAt: new Date(),
216
+ },
217
+ });
218
+
219
+ await ctx.context.internalAdapter.createAccount({
220
+ userId: session.user.id,
221
+ providerId: "siwn",
222
+ accountId: `${accountId}:${network}`,
223
+ createdAt: new Date(),
224
+ updatedAt: new Date(),
225
+ });
226
+
227
+ return ctx.json({
228
+ success: true,
229
+ accountId,
230
+ network,
231
+ message: "NEAR account successfully linked"
232
+ });
233
+ } catch (error: unknown) {
234
+ if (error instanceof APIError) throw error;
235
+ throw new APIError("UNAUTHORIZED", {
236
+ message: "Something went wrong. Please try again later.",
237
+ error: error instanceof Error ? error.message : "Unknown error",
238
+ status: 401,
239
+ });
240
+ }
241
+ },
242
+ ),
243
+ unlinkNearAccount: createAuthEndpoint(
244
+ "/near/unlink-account",
245
+ {
246
+ method: "POST",
247
+ body: z.object({
248
+ accountId: z.string(),
249
+ network: z.enum(["mainnet", "testnet"]).optional(),
250
+ }),
251
+ use: [sessionMiddleware],
252
+ },
253
+ async (ctx) => {
254
+ const { accountId, network: providedNetwork } = ctx.body;
255
+ const session = ctx.context.session;
256
+
257
+ if (!session) {
258
+ throw new APIError("UNAUTHORIZED", {
259
+ message: "Must be logged in to unlink NEAR account",
260
+ status: 401,
261
+ });
262
+ }
263
+
264
+ const network = providedNetwork || getNetworkFromAccountId(accountId);
265
+
266
+ // Find the NEAR account to unlink
267
+ const nearAccount: NearAccount | null = await ctx.context.adapter.findOne({
268
+ model: "nearAccount",
269
+ where: [
270
+ { field: "userId", operator: "eq", value: session.user.id },
271
+ { field: "accountId", operator: "eq", value: accountId },
272
+ { field: "network", operator: "eq", value: network },
273
+ ],
274
+ });
275
+
276
+ if (!nearAccount) {
277
+ throw new APIError("NOT_FOUND", {
278
+ message: "NEAR account not found or not linked to your user",
279
+ status: 404,
280
+ });
281
+ }
282
+
283
+ // Safety check: Don't allow unlinking if it's the only auth method
284
+ const accounts = await ctx.context.adapter.findMany({
285
+ model: "account",
286
+ where: [{ field: "userId", operator: "eq", value: session.user.id }],
287
+ });
288
+
289
+ if (accounts.length <= 1) {
290
+ throw new APIError("BAD_REQUEST", {
291
+ message: "Cannot unlink last authentication method. Link another account first.",
292
+ status: 400,
293
+ });
294
+ }
295
+
296
+ // If unlinking primary account, promote another one
297
+ if (nearAccount.isPrimary) {
298
+ const otherNearAccounts: NearAccount[] = await ctx.context.adapter.findMany({
299
+ model: "nearAccount",
300
+ where: [
301
+ { field: "userId", operator: "eq", value: session.user.id },
302
+ { field: "accountId", operator: "ne", value: accountId },
303
+ ],
304
+ });
305
+
306
+ if (otherNearAccounts.length > 0) {
307
+ // Promote the first other NEAR account to primary
308
+ await ctx.context.adapter.update({
309
+ model: "nearAccount",
310
+ where: [
311
+ { field: "id", operator: "eq", value: otherNearAccounts[0]!.id },
312
+ ],
313
+ update: { isPrimary: true },
314
+ });
315
+ }
316
+ }
317
+
318
+ // Delete the NEAR account record
319
+ await ctx.context.adapter.delete({
320
+ model: "nearAccount",
321
+ where: [
322
+ { field: "userId", operator: "eq", value: session.user.id },
323
+ { field: "accountId", operator: "eq", value: accountId },
324
+ { field: "network", operator: "eq", value: network },
325
+ ],
326
+ });
327
+
328
+ // Delete the associated account record
329
+ const accountToDelete: Account | null = await ctx.context.adapter.findOne({
330
+ model: "account",
331
+ where: [
332
+ { field: "userId", operator: "eq", value: session.user.id },
333
+ { field: "providerId", operator: "eq", value: "siwn" },
334
+ { field: "accountId", operator: "eq", value: `${accountId}:${network}` },
335
+ ],
336
+ });
337
+
338
+ if (accountToDelete) {
339
+ await ctx.context.internalAdapter.deleteAccount(accountToDelete.id);
340
+ }
341
+
342
+ return ctx.json({
343
+ success: true,
344
+ accountId,
345
+ network,
346
+ message: "NEAR account successfully unlinked"
347
+ });
348
+ },
349
+ ),
350
+ listNearAccounts: createAuthEndpoint(
351
+ "/near/list-accounts",
352
+ {
353
+ method: "GET",
354
+ use: [sessionMiddleware],
355
+ },
356
+ async (ctx) => {
357
+ const session = ctx.context.session;
358
+
359
+ const nearAccounts: NearAccount[] = await ctx.context.adapter.findMany({
360
+ model: "nearAccount",
361
+ where: [{ field: "userId", operator: "eq", value: session.user.id }],
362
+ });
363
+
364
+ return ctx.json({ accounts: nearAccounts });
365
+ },
366
+ ),
105
367
  getSiwnNonce: createAuthEndpoint(
106
368
  "/near/nonce",
107
369
  {
@@ -111,7 +373,7 @@ export const siwn = (options: SIWNPluginOptions) =>
111
373
  async (ctx) => {
112
374
  const { accountId, publicKey, networkId } = ctx.body;
113
375
  const network = getNetworkFromAccountId(accountId);
114
-
376
+
115
377
  if (networkId !== network) {
116
378
  throw new APIError("BAD_REQUEST", {
117
379
  message: "Network ID mismatch with account ID",
@@ -393,4 +655,4 @@ export const siwn = (options: SIWNPluginOptions) =>
393
655
  },
394
656
  ),
395
657
  },
396
- }) satisfies BetterAuthPlugin;
658
+ }) satisfies BetterAuthPlugin;