atproto-better-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/dist/index.js ADDED
@@ -0,0 +1,534 @@
1
+ // src/server.ts
2
+ import { NodeOAuthClient, JoseKey } from "@atproto/oauth-client-node";
3
+ import { Agent } from "@atproto/api";
4
+ import { createAuthEndpoint } from "better-auth/api";
5
+ import { z } from "zod";
6
+
7
+ // src/schema.ts
8
+ var atprotoUserSchema = {
9
+ user: {
10
+ fields: {
11
+ atprotoDid: {
12
+ type: "string",
13
+ required: false,
14
+ unique: true
15
+ },
16
+ atprotoHandle: {
17
+ type: "string",
18
+ required: false
19
+ },
20
+ atprotoBio: {
21
+ type: "string",
22
+ required: false
23
+ },
24
+ atprotoBanner: {
25
+ type: "string",
26
+ required: false
27
+ }
28
+ }
29
+ }
30
+ };
31
+ var atprotoStateSchema = {
32
+ atprotoState: {
33
+ fields: {
34
+ key: {
35
+ type: "string",
36
+ required: true,
37
+ unique: true
38
+ },
39
+ state: {
40
+ type: "string",
41
+ required: true
42
+ },
43
+ expiresAt: {
44
+ type: "date",
45
+ required: true
46
+ }
47
+ }
48
+ }
49
+ };
50
+ var atprotoSessionSchema = {
51
+ atprotoSession: {
52
+ fields: {
53
+ did: {
54
+ type: "string",
55
+ required: true,
56
+ unique: true
57
+ },
58
+ session: {
59
+ type: "string",
60
+ required: true
61
+ },
62
+ userId: {
63
+ type: "string",
64
+ required: true,
65
+ references: {
66
+ model: "user",
67
+ field: "id"
68
+ }
69
+ },
70
+ updatedAt: {
71
+ type: "date",
72
+ required: true
73
+ }
74
+ }
75
+ }
76
+ };
77
+ var atprotoSchema = {
78
+ ...atprotoUserSchema,
79
+ ...atprotoStateSchema,
80
+ ...atprotoSessionSchema
81
+ };
82
+
83
+ // src/server.ts
84
+ function getAppOrigin(authBaseURL) {
85
+ try {
86
+ return new URL(authBaseURL).origin;
87
+ } catch {
88
+ return authBaseURL;
89
+ }
90
+ }
91
+ function atprotoAuth(options) {
92
+ const {
93
+ clientMetadata,
94
+ privateKey,
95
+ mapProfileToUser
96
+ } = options;
97
+ let oauthClient = null;
98
+ let keysetPromise = null;
99
+ let resolvedClientMetadata = null;
100
+ function buildClientMetadata(authBaseURL) {
101
+ if (resolvedClientMetadata)
102
+ return resolvedClientMetadata;
103
+ const appOrigin = getAppOrigin(authBaseURL);
104
+ resolvedClientMetadata = {
105
+ client_id: clientMetadata.clientId ?? `${appOrigin}/client-metadata.json`,
106
+ client_name: clientMetadata.clientName,
107
+ client_uri: clientMetadata.clientUri ?? appOrigin,
108
+ logo_uri: clientMetadata.logoUri,
109
+ tos_uri: clientMetadata.tosUri,
110
+ policy_uri: clientMetadata.policyUri,
111
+ redirect_uris: clientMetadata.redirectUris ?? [`${authBaseURL}/callback/atproto`],
112
+ grant_types: ["authorization_code", "refresh_token"],
113
+ response_types: ["code"],
114
+ scope: clientMetadata.scope ?? "atproto transition:generic",
115
+ dpop_bound_access_tokens: true,
116
+ application_type: "web",
117
+ token_endpoint_auth_method: clientMetadata.jwksUri ?? `${appOrigin}/jwks.json` ? "private_key_jwt" : "none",
118
+ token_endpoint_auth_signing_alg: clientMetadata.jwksUri ?? `${appOrigin}/jwks.json` ? "ES256" : undefined,
119
+ jwks_uri: clientMetadata.jwksUri ?? `${appOrigin}/jwks.json`
120
+ };
121
+ return resolvedClientMetadata;
122
+ }
123
+ async function getOAuthClient(adapter, authBaseURL) {
124
+ if (oauthClient)
125
+ return oauthClient;
126
+ if (!keysetPromise) {
127
+ keysetPromise = Promise.all([
128
+ JoseKey.fromImportable(JSON.stringify(privateKey))
129
+ ]);
130
+ }
131
+ const keyset = await keysetPromise;
132
+ const fullClientMetadata = buildClientMetadata(authBaseURL);
133
+ oauthClient = new NodeOAuthClient({
134
+ clientMetadata: {
135
+ ...fullClientMetadata,
136
+ redirect_uris: fullClientMetadata.redirect_uris
137
+ },
138
+ keyset,
139
+ stateStore: {
140
+ async set(key, state) {
141
+ try {
142
+ await adapter.delete({
143
+ model: "atprotoState",
144
+ where: [{ field: "key", value: key }]
145
+ });
146
+ } catch {}
147
+ await adapter.create({
148
+ model: "atprotoState",
149
+ data: {
150
+ key,
151
+ state: JSON.stringify(state),
152
+ expiresAt: new Date(Date.now() + 10 * 60 * 1000)
153
+ }
154
+ });
155
+ },
156
+ async get(key) {
157
+ const record = await adapter.findOne({
158
+ model: "atprotoState",
159
+ where: [{ field: "key", value: key }]
160
+ });
161
+ if (!record)
162
+ return;
163
+ if (new Date(record.expiresAt) < new Date) {
164
+ await adapter.delete({
165
+ model: "atprotoState",
166
+ where: [{ field: "key", value: key }]
167
+ });
168
+ return;
169
+ }
170
+ return JSON.parse(record.state);
171
+ },
172
+ async del(key) {
173
+ await adapter.delete({
174
+ model: "atprotoState",
175
+ where: [{ field: "key", value: key }]
176
+ });
177
+ }
178
+ },
179
+ sessionStore: {
180
+ async set(did, session) {
181
+ const existing = await adapter.findOne({
182
+ model: "atprotoSession",
183
+ where: [{ field: "did", value: did }]
184
+ });
185
+ if (existing) {
186
+ await adapter.update({
187
+ model: "atprotoSession",
188
+ where: [{ field: "did", value: did }],
189
+ update: {
190
+ session: JSON.stringify(session),
191
+ updatedAt: new Date
192
+ }
193
+ });
194
+ } else {
195
+ await adapter.create({
196
+ model: "atprotoSession",
197
+ data: {
198
+ did,
199
+ session: JSON.stringify(session),
200
+ userId: "",
201
+ updatedAt: new Date
202
+ }
203
+ });
204
+ }
205
+ },
206
+ async get(did) {
207
+ const record = await adapter.findOne({
208
+ model: "atprotoSession",
209
+ where: [{ field: "did", value: did }]
210
+ });
211
+ if (!record || !record.session)
212
+ return;
213
+ return JSON.parse(record.session);
214
+ },
215
+ async del(did) {
216
+ await adapter.delete({
217
+ model: "atprotoSession",
218
+ where: [{ field: "did", value: did }]
219
+ });
220
+ }
221
+ }
222
+ });
223
+ return oauthClient;
224
+ }
225
+ return {
226
+ id: "atproto",
227
+ schema: atprotoSchema,
228
+ endpoints: {
229
+ signInAtproto: createAuthEndpoint("/atproto/sign-in", {
230
+ method: "GET",
231
+ query: z.object({
232
+ handle: z.string().min(1),
233
+ callbackURL: z.string().optional()
234
+ })
235
+ }, async (ctx) => {
236
+ const { handle, callbackURL } = ctx.query;
237
+ const client = await getOAuthClient(ctx.context.adapter, ctx.context.baseURL);
238
+ const state = callbackURL ? JSON.stringify({ callbackURL }) : undefined;
239
+ const authUrl = await client.authorize(handle, { state });
240
+ throw ctx.redirect(authUrl.toString());
241
+ }),
242
+ callbackAtproto: createAuthEndpoint("/callback/atproto", {
243
+ method: "GET",
244
+ query: z.object({
245
+ code: z.string().optional(),
246
+ state: z.string().optional(),
247
+ iss: z.string().optional(),
248
+ error: z.string().optional(),
249
+ error_description: z.string().optional()
250
+ })
251
+ }, async (ctx) => {
252
+ const client = await getOAuthClient(ctx.context.adapter, ctx.context.baseURL);
253
+ const url = new URL(ctx.request?.url ?? "", "http://localhost");
254
+ const params = url.searchParams;
255
+ const { session: atprotoSession, state } = await client.callback(params);
256
+ let callbackURL = "/";
257
+ if (state) {
258
+ try {
259
+ const parsed = JSON.parse(state);
260
+ if (parsed.callbackURL) {
261
+ callbackURL = parsed.callbackURL;
262
+ }
263
+ } catch {}
264
+ }
265
+ const agent = new Agent(atprotoSession);
266
+ const profileResponse = await agent.getProfile({
267
+ actor: atprotoSession.did
268
+ });
269
+ const profile = {
270
+ did: atprotoSession.did,
271
+ handle: profileResponse.data.handle,
272
+ displayName: profileResponse.data.displayName,
273
+ avatar: profileResponse.data.avatar,
274
+ description: profileResponse.data.description,
275
+ banner: profileResponse.data.banner
276
+ };
277
+ const existingAccount = await ctx.context.adapter.findOne({
278
+ model: "account",
279
+ where: [
280
+ { field: "providerId", value: "atproto" },
281
+ { field: "accountId", value: profile.did }
282
+ ]
283
+ });
284
+ let userId;
285
+ if (existingAccount) {
286
+ userId = existingAccount.userId;
287
+ await ctx.context.adapter.update({
288
+ model: "account",
289
+ where: [{ field: "id", value: existingAccount.id }],
290
+ update: {
291
+ accessToken: "atproto-session",
292
+ updatedAt: new Date
293
+ }
294
+ });
295
+ await ctx.context.adapter.update({
296
+ model: "user",
297
+ where: [{ field: "id", value: userId }],
298
+ update: {
299
+ atprotoHandle: profile.handle,
300
+ atprotoBio: profile.description,
301
+ atprotoBanner: profile.banner,
302
+ image: profile.avatar,
303
+ name: profile.displayName ?? profile.handle,
304
+ updatedAt: new Date
305
+ }
306
+ });
307
+ } else {
308
+ const currentSession = ctx.context.session;
309
+ if (currentSession?.user) {
310
+ userId = currentSession.user.id;
311
+ } else {
312
+ const userFields = mapProfileToUser ? mapProfileToUser(profile) : {};
313
+ const newUser = await ctx.context.internalAdapter.createUser({
314
+ name: profile.displayName ?? profile.handle,
315
+ email: `${profile.did}@atproto.invalid`,
316
+ image: profile.avatar,
317
+ emailVerified: false,
318
+ atprotoDid: profile.did,
319
+ atprotoHandle: profile.handle,
320
+ atprotoBio: profile.description,
321
+ atprotoBanner: profile.banner,
322
+ ...userFields
323
+ });
324
+ userId = newUser.id;
325
+ }
326
+ await ctx.context.adapter.create({
327
+ model: "account",
328
+ data: {
329
+ userId,
330
+ providerId: "atproto",
331
+ accountId: profile.did,
332
+ accessToken: "atproto-session",
333
+ refreshToken: null,
334
+ expiresAt: null,
335
+ scope: clientMetadata.scope ?? "atproto transition:generic",
336
+ createdAt: new Date,
337
+ updatedAt: new Date
338
+ }
339
+ });
340
+ }
341
+ const existingAtprotoSession = await ctx.context.adapter.findOne({
342
+ model: "atprotoSession",
343
+ where: [{ field: "did", value: profile.did }]
344
+ });
345
+ if (existingAtprotoSession) {
346
+ await ctx.context.adapter.update({
347
+ model: "atprotoSession",
348
+ where: [{ field: "did", value: profile.did }],
349
+ update: {
350
+ userId,
351
+ updatedAt: new Date
352
+ }
353
+ });
354
+ } else {
355
+ await ctx.context.adapter.create({
356
+ model: "atprotoSession",
357
+ data: {
358
+ did: profile.did,
359
+ session: "",
360
+ userId,
361
+ updatedAt: new Date
362
+ }
363
+ });
364
+ }
365
+ const session = await ctx.context.internalAdapter.createSession(userId, undefined, undefined);
366
+ const sessionCookie = ctx.context.authCookies.sessionToken;
367
+ await ctx.setSignedCookie(sessionCookie.name, session.token, ctx.context.secret, sessionCookie.attributes);
368
+ throw ctx.redirect(callbackURL);
369
+ }),
370
+ getAtprotoSession: createAuthEndpoint("/atproto/session", {
371
+ method: "GET"
372
+ }, async (ctx) => {
373
+ const currentSession = ctx.context.session;
374
+ if (!currentSession?.user) {
375
+ return ctx.json({ session: null });
376
+ }
377
+ const account = await ctx.context.adapter.findOne({
378
+ model: "account",
379
+ where: [
380
+ { field: "userId", value: currentSession.user.id },
381
+ { field: "providerId", value: "atproto" }
382
+ ]
383
+ });
384
+ if (!account) {
385
+ return ctx.json({ session: null });
386
+ }
387
+ const client = await getOAuthClient(ctx.context.adapter, ctx.context.baseURL);
388
+ try {
389
+ const atprotoSession = await client.restore(account.accountId);
390
+ const agent = new Agent(atprotoSession);
391
+ const profileResponse = await agent.getProfile({
392
+ actor: atprotoSession.did
393
+ });
394
+ const sessionInfo = {
395
+ did: atprotoSession.did,
396
+ handle: profileResponse.data.handle,
397
+ displayName: profileResponse.data.displayName,
398
+ avatar: profileResponse.data.avatar,
399
+ active: true
400
+ };
401
+ return ctx.json({ session: sessionInfo });
402
+ } catch {
403
+ return ctx.json({
404
+ session: {
405
+ did: account.accountId,
406
+ handle: "",
407
+ active: false
408
+ }
409
+ });
410
+ }
411
+ }),
412
+ restoreAtprotoAgent: createAuthEndpoint("/atproto/restore", {
413
+ method: "POST"
414
+ }, async (ctx) => {
415
+ const currentSession = ctx.context.session;
416
+ if (!currentSession?.user) {
417
+ return ctx.json({ error: "Not authenticated" }, { status: 401 });
418
+ }
419
+ const account = await ctx.context.adapter.findOne({
420
+ model: "account",
421
+ where: [
422
+ { field: "userId", value: currentSession.user.id },
423
+ { field: "providerId", value: "atproto" }
424
+ ]
425
+ });
426
+ if (!account) {
427
+ return ctx.json({ error: "No ATProto account linked" }, { status: 404 });
428
+ }
429
+ const client = await getOAuthClient(ctx.context.adapter, ctx.context.baseURL);
430
+ try {
431
+ const atprotoSession = await client.restore(account.accountId);
432
+ return ctx.json({
433
+ did: atprotoSession.did,
434
+ active: true
435
+ });
436
+ } catch (error) {
437
+ return ctx.json({
438
+ error: "Failed to restore ATProto session",
439
+ details: error instanceof Error ? error.message : "Unknown error"
440
+ }, { status: 400 });
441
+ }
442
+ })
443
+ }
444
+ };
445
+ }
446
+ // src/utils.ts
447
+ function getPublicJwk(privateKey) {
448
+ const { d: _privateComponent, ...publicKey } = privateKey;
449
+ return publicKey;
450
+ }
451
+ function createJwks(privateKey) {
452
+ return {
453
+ keys: [getPublicJwk(privateKey)]
454
+ };
455
+ }
456
+ function createClientMetadata(options) {
457
+ const { clientMetadata } = options;
458
+ const hasJwks = !!clientMetadata.jwksUri;
459
+ return {
460
+ client_id: clientMetadata.clientId,
461
+ client_name: clientMetadata.clientName,
462
+ client_uri: clientMetadata.clientUri,
463
+ logo_uri: clientMetadata.logoUri,
464
+ tos_uri: clientMetadata.tosUri,
465
+ policy_uri: clientMetadata.policyUri,
466
+ redirect_uris: clientMetadata.redirectUris,
467
+ grant_types: ["authorization_code", "refresh_token"],
468
+ response_types: ["code"],
469
+ scope: clientMetadata.scope ?? "atproto transition:generic",
470
+ dpop_bound_access_tokens: true,
471
+ application_type: "web",
472
+ token_endpoint_auth_method: hasJwks ? "private_key_jwt" : "none",
473
+ token_endpoint_auth_signing_alg: hasJwks ? "ES256" : undefined,
474
+ jwks_uri: clientMetadata.jwksUri
475
+ };
476
+ }
477
+ async function generateES256Key() {
478
+ const keyPair = await crypto.subtle.generateKey({
479
+ name: "ECDSA",
480
+ namedCurve: "P-256"
481
+ }, true, ["sign", "verify"]);
482
+ const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
483
+ return {
484
+ kty: "EC",
485
+ crv: "P-256",
486
+ x: privateJwk.x,
487
+ y: privateJwk.y,
488
+ d: privateJwk.d,
489
+ alg: "ES256",
490
+ kid: crypto.randomUUID()
491
+ };
492
+ }
493
+ function isValidES256PrivateKey(jwk) {
494
+ if (!jwk || typeof jwk !== "object")
495
+ return false;
496
+ const key = jwk;
497
+ return key.kty === "EC" && key.crv === "P-256" && typeof key.x === "string" && typeof key.y === "string" && typeof key.d === "string";
498
+ }
499
+ function createClientMetadataHandler(options) {
500
+ const metadata = createClientMetadata(options);
501
+ return () => {
502
+ return new Response(JSON.stringify(metadata), {
503
+ headers: {
504
+ "Content-Type": "application/json"
505
+ }
506
+ });
507
+ };
508
+ }
509
+ function createJwksHandler(privateKey) {
510
+ const jwks = createJwks(privateKey);
511
+ return () => {
512
+ return new Response(JSON.stringify(jwks), {
513
+ headers: {
514
+ "Content-Type": "application/json"
515
+ }
516
+ });
517
+ };
518
+ }
519
+ var DEFAULT_ATPROTO_SCOPES = "atproto transition:generic";
520
+ export {
521
+ isValidES256PrivateKey,
522
+ getPublicJwk,
523
+ generateES256Key,
524
+ createJwksHandler,
525
+ createJwks,
526
+ createClientMetadataHandler,
527
+ createClientMetadata,
528
+ atprotoUserSchema,
529
+ atprotoStateSchema,
530
+ atprotoSessionSchema,
531
+ atprotoSchema,
532
+ atprotoAuth,
533
+ DEFAULT_ATPROTO_SCOPES
534
+ };
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Database schema definitions for the ATProto better-auth plugin.
3
+ * These tables store OAuth state and session data required by @atproto/oauth-client-node.
4
+ */
5
+ /**
6
+ * Schema extensions for the user table.
7
+ * Adds ATProto-specific fields to store profile data.
8
+ */
9
+ export declare const atprotoUserSchema: {
10
+ readonly user: {
11
+ readonly fields: {
12
+ readonly atprotoDid: {
13
+ readonly type: "string";
14
+ readonly required: false;
15
+ readonly unique: true;
16
+ };
17
+ readonly atprotoHandle: {
18
+ readonly type: "string";
19
+ readonly required: false;
20
+ };
21
+ readonly atprotoBio: {
22
+ readonly type: "string";
23
+ readonly required: false;
24
+ };
25
+ readonly atprotoBanner: {
26
+ readonly type: "string";
27
+ readonly required: false;
28
+ };
29
+ };
30
+ };
31
+ };
32
+ /**
33
+ * Schema for the atprotoState table.
34
+ * Stores temporary OAuth state during the authorization flow.
35
+ */
36
+ export declare const atprotoStateSchema: {
37
+ readonly atprotoState: {
38
+ readonly fields: {
39
+ readonly key: {
40
+ readonly type: "string";
41
+ readonly required: true;
42
+ readonly unique: true;
43
+ };
44
+ readonly state: {
45
+ readonly type: "string";
46
+ readonly required: true;
47
+ };
48
+ readonly expiresAt: {
49
+ readonly type: "date";
50
+ readonly required: true;
51
+ };
52
+ };
53
+ };
54
+ };
55
+ /**
56
+ * Schema for the atprotoSession table.
57
+ * Stores ATProto OAuth sessions including tokens, DPoP keys, etc.
58
+ * This is separate from better-auth's session table because ATProto
59
+ * sessions contain additional cryptographic material needed for API calls.
60
+ */
61
+ export declare const atprotoSessionSchema: {
62
+ readonly atprotoSession: {
63
+ readonly fields: {
64
+ readonly did: {
65
+ readonly type: "string";
66
+ readonly required: true;
67
+ readonly unique: true;
68
+ };
69
+ readonly session: {
70
+ readonly type: "string";
71
+ readonly required: true;
72
+ };
73
+ readonly userId: {
74
+ readonly type: "string";
75
+ readonly required: true;
76
+ readonly references: {
77
+ readonly model: "user";
78
+ readonly field: "id";
79
+ };
80
+ };
81
+ readonly updatedAt: {
82
+ readonly type: "date";
83
+ readonly required: true;
84
+ };
85
+ };
86
+ };
87
+ };
88
+ /**
89
+ * Combined schema for the plugin
90
+ */
91
+ export declare const atprotoSchema: {
92
+ readonly atprotoSession: {
93
+ readonly fields: {
94
+ readonly did: {
95
+ readonly type: "string";
96
+ readonly required: true;
97
+ readonly unique: true;
98
+ };
99
+ readonly session: {
100
+ readonly type: "string";
101
+ readonly required: true;
102
+ };
103
+ readonly userId: {
104
+ readonly type: "string";
105
+ readonly required: true;
106
+ readonly references: {
107
+ readonly model: "user";
108
+ readonly field: "id";
109
+ };
110
+ };
111
+ readonly updatedAt: {
112
+ readonly type: "date";
113
+ readonly required: true;
114
+ };
115
+ };
116
+ };
117
+ readonly atprotoState: {
118
+ readonly fields: {
119
+ readonly key: {
120
+ readonly type: "string";
121
+ readonly required: true;
122
+ readonly unique: true;
123
+ };
124
+ readonly state: {
125
+ readonly type: "string";
126
+ readonly required: true;
127
+ };
128
+ readonly expiresAt: {
129
+ readonly type: "date";
130
+ readonly required: true;
131
+ };
132
+ };
133
+ };
134
+ readonly user: {
135
+ readonly fields: {
136
+ readonly atprotoDid: {
137
+ readonly type: "string";
138
+ readonly required: false;
139
+ readonly unique: true;
140
+ };
141
+ readonly atprotoHandle: {
142
+ readonly type: "string";
143
+ readonly required: false;
144
+ };
145
+ readonly atprotoBio: {
146
+ readonly type: "string";
147
+ readonly required: false;
148
+ };
149
+ readonly atprotoBanner: {
150
+ readonly type: "string";
151
+ readonly required: false;
152
+ };
153
+ };
154
+ };
155
+ };
@@ -0,0 +1,7 @@
1
+ import type { BetterAuthPlugin } from "better-auth";
2
+ import type { AtprotoAuthOptions } from "./types.js";
3
+ /**
4
+ * Creates the ATProto better-auth plugin for server-side use.
5
+ */
6
+ export declare function atprotoAuth(options: AtprotoAuthOptions): BetterAuthPlugin;
7
+ export type { AtprotoAuthOptions };