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/README.md ADDED
@@ -0,0 +1,348 @@
1
+ # atproto-better-auth
2
+
3
+ A [better-auth](https://better-auth.com) plugin for authenticating with [ATProto](https://atproto.com)/[Bluesky](https://bsky.app).
4
+
5
+ ## Repository Structure
6
+
7
+ This is a monorepo containing:
8
+
9
+ - `packages/atproto-better-auth` - The main plugin package
10
+ - `apps/example` - A Next.js example application
11
+
12
+ To get started with the example, see [apps/example/README.md](./apps/example/README.md).
13
+
14
+ ## Features
15
+
16
+ - Full ATProto OAuth support (PKCE, PAR, DPoP)
17
+ - Automatic token refresh
18
+ - Account linking with existing users
19
+ - Server-side Agent restoration for API calls
20
+ - TypeScript support
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ bun add atproto-better-auth
26
+ # or
27
+ npm install atproto-better-auth
28
+ # or
29
+ pnpm add atproto-better-auth
30
+ ```
31
+
32
+ ## Prerequisites
33
+
34
+ ### 1. Generate an ES256 Key Pair
35
+
36
+ ATProto OAuth requires an ES256 (ECDSA P-256) key pair. You can generate one using the included utility:
37
+
38
+ ```typescript
39
+ import { generateES256Key } from "atproto-better-auth";
40
+
41
+ const privateKey = await generateES256Key();
42
+ console.log(JSON.stringify(privateKey, null, 2));
43
+ ```
44
+
45
+ Or generate one at [jwkset.com/generate](https://jwkset.com/generate):
46
+
47
+ - **Key type**: ECDSA
48
+ - **Key algorithm**: ES256
49
+
50
+ The key should look like:
51
+
52
+ ```json
53
+ {
54
+ "kty": "EC",
55
+ "alg": "ES256",
56
+ "kid": "your-key-id",
57
+ "crv": "P-256",
58
+ "x": "...",
59
+ "y": "...",
60
+ "d": "..."
61
+ }
62
+ ```
63
+
64
+ > **Important**: Keep the `"d"` field secret! This is your private key.
65
+
66
+ ### 2. Host Required Endpoints
67
+
68
+ ATProto OAuth requires two publicly accessible JSON endpoints:
69
+
70
+ #### `/client-metadata.json`
71
+
72
+ Your OAuth client metadata. The URL to this file becomes your `client_id`.
73
+
74
+ #### `/jwks.json`
75
+
76
+ Your public key(s) in JWKS format. This is your private key **without** the `"d"` field.
77
+
78
+ See [Setup Examples](#setup-examples) below for how to create these endpoints.
79
+
80
+ ## Quick Start
81
+
82
+ ### Server Setup
83
+
84
+ ```typescript
85
+ // lib/auth.ts
86
+ import { betterAuth } from "better-auth";
87
+ import { atprotoAuth } from "atproto-better-auth";
88
+
89
+ // Your ES256 private key (store securely, e.g., in environment variables)
90
+ const privateKey = JSON.parse(process.env.ATPROTO_PRIVATE_KEY!);
91
+
92
+ export const auth = betterAuth({
93
+ database: {
94
+ // Your database configuration
95
+ },
96
+ plugins: [
97
+ atprotoAuth({
98
+ clientMetadata: {
99
+ clientId: "https://yourapp.com/client-metadata.json",
100
+ clientName: "Your App Name",
101
+ clientUri: "https://yourapp.com",
102
+ redirectUris: ["https://yourapp.com/api/auth/callback/atproto"],
103
+ jwksUri: "https://yourapp.com/jwks.json",
104
+ // Optional
105
+ logoUri: "https://yourapp.com/logo.png",
106
+ tosUri: "https://yourapp.com/terms",
107
+ policyUri: "https://yourapp.com/privacy",
108
+ },
109
+ privateKey,
110
+ }),
111
+ ],
112
+ });
113
+ ```
114
+
115
+ ### Client Setup
116
+
117
+ ```typescript
118
+ // lib/auth-client.ts
119
+ import { createAuthClient } from "better-auth/client";
120
+ import { atprotoAuthClient } from "atproto-better-auth/client";
121
+
122
+ export const authClient = createAuthClient({
123
+ plugins: [atprotoAuthClient()],
124
+ });
125
+ ```
126
+
127
+ ### Sign In
128
+
129
+ ```typescript
130
+ // In your component/page
131
+ import { authClient } from "@/lib/auth-client";
132
+
133
+ function SignInButton() {
134
+ const handleSignIn = () => {
135
+ authClient.signIn.atproto({
136
+ handle: "user.bsky.social", // The user's Bluesky handle
137
+ callbackURL: "/dashboard", // Where to redirect after auth
138
+ });
139
+ };
140
+
141
+ return <button onClick={handleSignIn}>Sign in with Bluesky</button>;
142
+ }
143
+ ```
144
+
145
+ ## Setup Examples
146
+
147
+ ### Next.js App Router
148
+
149
+ #### `/app/client-metadata.json/route.ts`
150
+
151
+ ```typescript
152
+ import { createClientMetadataHandler } from "atproto-better-auth";
153
+
154
+ const privateKey = JSON.parse(process.env.ATPROTO_PRIVATE_KEY!);
155
+
156
+ const options = {
157
+ clientMetadata: {
158
+ clientId: "https://yourapp.com/client-metadata.json",
159
+ clientName: "Your App",
160
+ redirectUris: ["https://yourapp.com/api/auth/callback/atproto"],
161
+ jwksUri: "https://yourapp.com/jwks.json",
162
+ },
163
+ privateKey,
164
+ };
165
+
166
+ export const GET = createClientMetadataHandler(options);
167
+ ```
168
+
169
+ #### `/app/jwks.json/route.ts`
170
+
171
+ ```typescript
172
+ import { createJwksHandler } from "atproto-better-auth";
173
+
174
+ const privateKey = JSON.parse(process.env.ATPROTO_PRIVATE_KEY!);
175
+
176
+ export const GET = createJwksHandler(privateKey);
177
+ ```
178
+
179
+ ### Express/Hono/Other Frameworks
180
+
181
+ ```typescript
182
+ import { createClientMetadata, createJwks } from "atproto-better-auth";
183
+
184
+ // Serve client metadata
185
+ app.get("/client-metadata.json", (req, res) => {
186
+ const metadata = createClientMetadata(authOptions);
187
+ res.json(metadata);
188
+ });
189
+
190
+ // Serve JWKS
191
+ app.get("/jwks.json", (req, res) => {
192
+ const jwks = createJwks(privateKey);
193
+ res.json(jwks);
194
+ });
195
+ ```
196
+
197
+ ## API Reference
198
+
199
+ ### Server Plugin Options
200
+
201
+ ```typescript
202
+ interface AtprotoAuthOptions {
203
+ clientMetadata: {
204
+ clientId: string; // URL to your client-metadata.json
205
+ clientName: string; // Your app's display name
206
+ clientUri?: string; // Your app's homepage
207
+ logoUri?: string; // URL to your logo
208
+ tosUri?: string; // Terms of service URL
209
+ policyUri?: string; // Privacy policy URL
210
+ redirectUris: string[]; // OAuth callback URLs
211
+ jwksUri?: string; // URL to your JWKS endpoint
212
+ scope?: string; // OAuth scopes (default: "atproto transition:generic")
213
+ };
214
+
215
+ privateKey: ES256PrivateJwk; // Your ES256 private key
216
+
217
+ callbackPath?: string; // Custom callback path (default: "/api/auth/callback/atproto")
218
+
219
+ mapProfileToUser?: (profile: AtprotoProfile) => Partial<User>; // Custom profile mapping
220
+ }
221
+ ```
222
+
223
+ ### Client Methods
224
+
225
+ ```typescript
226
+ // Sign in with ATProto
227
+ authClient.signIn.atproto({
228
+ handle: string; // User's ATProto handle
229
+ callbackURL?: string; // Redirect URL after auth
230
+ });
231
+
232
+ // Get current ATProto session info
233
+ const { session } = await authClient.atproto.getSession();
234
+ // session: { did, handle, displayName, avatar, active } | null
235
+
236
+ // Restore/refresh ATProto session
237
+ const result = await authClient.atproto.restore();
238
+ // result: { did, active } | { error }
239
+ ```
240
+
241
+ ### Utility Functions
242
+
243
+ ```typescript
244
+ import {
245
+ generateES256Key, // Generate a new ES256 key pair
246
+ getPublicJwk, // Extract public key from private key
247
+ createJwks, // Create JWKS object for endpoint
248
+ createClientMetadata, // Create client metadata for endpoint
249
+ isValidES256PrivateKey // Validate a JWK is a valid ES256 private key
250
+ } from "atproto-better-auth";
251
+ ```
252
+
253
+ ## Database Schema
254
+
255
+ The plugin extends the `user` table and adds two new tables:
256
+
257
+ ### User Table Extensions
258
+
259
+ | Column | Type | Description |
260
+ |--------|------|-------------|
261
+ | atprotoDid | string | User's ATProto DID (unique) |
262
+ | atprotoHandle | string | User's handle (e.g., `alice.bsky.social`) |
263
+ | atprotoBio | string | User's bio/description |
264
+ | atprotoBanner | string | URL to banner/cover image |
265
+
266
+ ### `atprotoState`
267
+
268
+ Stores temporary OAuth state during authorization flow.
269
+
270
+ | Column | Type | Description |
271
+ |--------|------|-------------|
272
+ | key | string | State key |
273
+ | state | string | Serialized OAuth state |
274
+ | expiresAt | date | Expiration timestamp |
275
+
276
+ ### `atprotoSession`
277
+
278
+ Stores ATProto OAuth sessions (tokens, DPoP keys, etc.).
279
+
280
+ | Column | Type | Description |
281
+ |--------|------|-------------|
282
+ | did | string | ATProto DID |
283
+ | session | string | Serialized session data |
284
+ | userId | string | Foreign key to user |
285
+ | updatedAt | date | Last update timestamp |
286
+
287
+ Run your database migrations after adding the plugin to create these tables and columns.
288
+
289
+ ## Server-Side API Calls
290
+
291
+ After authentication, you can make ATProto API calls on behalf of the user:
292
+
293
+ ```typescript
294
+ import { Agent } from "@atproto/api";
295
+ import { auth } from "@/lib/auth";
296
+
297
+ async function postToBluesky(userId: string, text: string) {
298
+ // Get the user's ATProto account
299
+ const account = await auth.api.getAccount({
300
+ userId,
301
+ providerId: "atproto",
302
+ });
303
+
304
+ if (!account) {
305
+ throw new Error("User not connected to ATProto");
306
+ }
307
+
308
+ // The OAuth client handles token refresh automatically
309
+ // You'll need to restore the session from the oauth client
310
+ // This is handled internally by the plugin's session store
311
+ }
312
+ ```
313
+
314
+ ## Local Development
315
+
316
+ For local development, you need a publicly accessible URL for ATProto servers to fetch your client metadata. Options:
317
+
318
+ 1. **ngrok**: `ngrok http 3000`
319
+ 2. **Cloudflare Tunnel**: `cloudflared tunnel`
320
+ 3. **localtunnel**: `lt --port 3000`
321
+
322
+ Update your `clientId` and `redirectUris` to use the tunnel URL.
323
+
324
+ ## Security Notes
325
+
326
+ 1. **Never expose your private key** - The `"d"` field in your JWK is your private key. Only the public key (without `"d"`) should be in your JWKS endpoint.
327
+
328
+ 2. **Store keys securely** - Use environment variables or a secrets manager for your private key.
329
+
330
+ 3. **Use HTTPS in production** - ATProto OAuth requires HTTPS URLs for client metadata and callbacks.
331
+
332
+ 4. **Key rotation** - Include a `kid` (Key ID) in your JWK to support key rotation.
333
+
334
+ ## How ATProto OAuth Works
335
+
336
+ Unlike traditional OAuth where you register with a central provider, ATProto is decentralized:
337
+
338
+ 1. **Self-hosted metadata**: Your app hosts its own client metadata at a public URL
339
+ 2. **Dynamic discovery**: ATProto servers fetch your metadata to verify your app
340
+ 3. **DPoP tokens**: Access tokens are bound to cryptographic proofs
341
+ 4. **Handle resolution**: User handles are resolved to find their PDS (Personal Data Server)
342
+ 5. **Account portability**: Users can migrate between servers while keeping their identity
343
+
344
+ This plugin handles all of this complexity using the official `@atproto/oauth-client-node` package.
345
+
346
+ ## License
347
+
348
+ MIT
@@ -0,0 +1,42 @@
1
+ import type { AtprotoSessionInfo, AtprotoSignInParams } from "./types.js";
2
+ import type { atprotoAuth } from "./server.js";
3
+ /**
4
+ * Client-side ATProto authentication plugin for better-auth.
5
+ */
6
+ export declare const atprotoAuthClient: () => {
7
+ id: "atproto";
8
+ $InferServerPlugin: ReturnType<typeof atprotoAuth>;
9
+ getActions($fetch: import("better-auth/client").BetterFetch): {
10
+ /**
11
+ * Sign in with ATProto/Bluesky
12
+ */
13
+ signIn: {
14
+ atproto: (params: AtprotoSignInParams) => Promise<void>;
15
+ };
16
+ atproto: {
17
+ /**
18
+ * Get the current ATProto session information
19
+ */
20
+ getSession: () => Promise<{
21
+ session: AtprotoSessionInfo | null;
22
+ }>;
23
+ /**
24
+ * Restore/refresh the ATProto session
25
+ * Useful to ensure the session is valid before making API calls
26
+ */
27
+ restore: () => Promise<{
28
+ did: string;
29
+ active: boolean;
30
+ } | {
31
+ error: string;
32
+ }>;
33
+ };
34
+ };
35
+ pathMethods: {
36
+ "/atproto/sign-in": "GET";
37
+ "/callback/atproto": "GET";
38
+ "/atproto/session": "GET";
39
+ "/atproto/restore": "POST";
40
+ };
41
+ };
42
+ export type { AtprotoSessionInfo, AtprotoSignInParams };
package/dist/client.js ADDED
@@ -0,0 +1,52 @@
1
+ // src/client.ts
2
+ var atprotoAuthClient = () => {
3
+ return {
4
+ id: "atproto",
5
+ $InferServerPlugin: {},
6
+ getActions($fetch) {
7
+ return {
8
+ signIn: {
9
+ atproto: async (params) => {
10
+ const { handle, callbackURL } = params;
11
+ const searchParams = new URLSearchParams({ handle });
12
+ if (callbackURL) {
13
+ searchParams.set("callbackURL", callbackURL);
14
+ }
15
+ if (typeof window !== "undefined") {
16
+ window.location.href = `/api/auth/atproto/sign-in?${searchParams.toString()}`;
17
+ }
18
+ }
19
+ },
20
+ atproto: {
21
+ getSession: async () => {
22
+ const response = await $fetch("/atproto/session", {
23
+ method: "GET"
24
+ });
25
+ if (!response.data) {
26
+ return { session: null };
27
+ }
28
+ return response.data;
29
+ },
30
+ restore: async () => {
31
+ const response = await $fetch("/atproto/restore", {
32
+ method: "POST"
33
+ });
34
+ if (!response.data) {
35
+ return { error: response.error?.message ?? "Unknown error" };
36
+ }
37
+ return response.data;
38
+ }
39
+ }
40
+ };
41
+ },
42
+ pathMethods: {
43
+ "/atproto/sign-in": "GET",
44
+ "/callback/atproto": "GET",
45
+ "/atproto/session": "GET",
46
+ "/atproto/restore": "POST"
47
+ }
48
+ };
49
+ };
50
+ export {
51
+ atprotoAuthClient
52
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * ATProto Better-Auth Plugin
3
+ *
4
+ * A better-auth plugin for authenticating with ATProto/Bluesky.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { betterAuth } from "better-auth";
9
+ * import { atprotoAuth } from "atproto-better-auth";
10
+ *
11
+ * export const auth = betterAuth({
12
+ * plugins: [
13
+ * atprotoAuth({
14
+ * clientMetadata: {
15
+ * clientId: "https://myapp.com/client-metadata.json",
16
+ * clientName: "My App",
17
+ * redirectUris: ["https://myapp.com/api/auth/callback/atproto"],
18
+ * },
19
+ * privateKey: JSON.parse(process.env.ATPROTO_PRIVATE_KEY!),
20
+ * }),
21
+ * ],
22
+ * });
23
+ * ```
24
+ */
25
+ export { atprotoAuth } from "./server.js";
26
+ export { atprotoSchema, atprotoUserSchema, atprotoStateSchema, atprotoSessionSchema } from "./schema.js";
27
+ export type { AtprotoAuthOptions, AtprotoClientMetadata, AtprotoClientMetadataInput, AtprotoProfile, AtprotoSessionInfo, AtprotoSignInParams, AtprotoStateRecord, AtprotoSessionRecord, ES256PrivateJwk, ES256PublicJwk, } from "./types.js";
28
+ export { getPublicJwk, createJwks, createClientMetadata, generateES256Key, isValidES256PrivateKey, createClientMetadataHandler, createJwksHandler, DEFAULT_ATPROTO_SCOPES, } from "./utils.js";
29
+ export type { CreateClientMetadataOptions } from "./utils.js";