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 +348 -0
- package/dist/client.d.ts +42 -0
- package/dist/client.js +52 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +534 -0
- package/dist/schema.d.ts +155 -0
- package/dist/server.d.ts +7 -0
- package/dist/types.d.ts +211 -0
- package/dist/utils.d.ts +88 -0
- package/package.json +58 -0
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
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|