better-near-auth 0.1.13 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-near-auth",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "Sign in with NEAR (SIWN) plugin for Better Auth",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
@@ -47,17 +47,16 @@
47
47
  "typescript": "^5.0.0"
48
48
  },
49
49
  "dependencies": {
50
- "@better-auth/utils": "^0.2.6",
51
50
  "fastintear": "^0.3.6",
52
51
  "nanostores": "^1.0.1",
53
52
  "near-sign-verify": "^0.4.3",
54
- "zod": "^4.0.17"
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.27",
59
+ "typescript": "^5.9.3",
61
60
  "vitest": "^3.2.4"
62
61
  },
63
62
  "files": [
package/src/client.ts CHANGED
@@ -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>;
@@ -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,7 +1,7 @@
1
1
  import { bytesToBase64 } from "fastintear/utils";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
3
3
  import { setSessionCookie } from "better-auth/cookies";
4
- import type { BetterAuthPlugin, User } from "better-auth/types";
4
+ import type { Account, BetterAuthPlugin, User } from "better-auth/types";
5
5
  import { generateNonce, parseAuthToken, verify, type VerificationResult, type VerifyOptions } from "near-sign-verify";
6
6
  import { defaultGetProfile, getImageUrl, getNetworkFromAccountId } from "./profile";
7
7
  import { schema } from "./schema";
@@ -18,6 +18,7 @@ import {
18
18
  VerifyRequest,
19
19
  VerifyResponse
20
20
  } from "./types";
21
+ import z from "zod";
21
22
  export * from "./types";
22
23
 
23
24
  function getOrigin(baseURL: string): string {
@@ -102,6 +103,267 @@ export const siwn = (options: SIWNPluginOptions) =>
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
  {
@@ -393,4 +655,4 @@ export const siwn = (options: SIWNPluginOptions) =>
393
655
  },
394
656
  ),
395
657
  },
396
- }) satisfies BetterAuthPlugin;
658
+ }) satisfies BetterAuthPlugin;