Meal planning and recipes sorted
at main 832 lines 22 kB view raw view rendered
1# AT Protocol OAuth with Next.js 2 3Build a Next.js app with AT Protocol OAuth authentication. 4 5**What you'll build:** A Next.js application where users can log in with their AT Protocol identity using OAuth. 6 7See this up and running at: https://nextjs-oauth-tutorial.up.railway.app/ 8 9--- 10 11## Prerequisites 12 13- Node.js 20+ 14- pnpm (or npm/yarn) 15- Basic familiarity with Next.js and TypeScript 16 17--- 18 19## Part 1: Project Setup 20 21### 1.1 Create Next.js App 22 23```bash 24npx create-next-app@latest my-app --yes 25cd my-app 26``` 27 28### 1.2 Install Dependencies 29 30```bash 31pnpm add @atproto/oauth-client-node 32``` 33 34That's it for now! We'll add more dependencies later. 35 36### 1.3 Run App 37```bash 38pnpm dev 39``` 40 41Run your Next app in hot reload mode. Open it in the browser at `http://127.0.0.1:3000`. Be sure to use `127.0.0.1` as opposed to `localhost`. This will be important for the oauth redirect flow. 42 43--- 44 45## Part 2: Basic OAuth (Localhost) 46 47Let's get OAuth working with the simplest possible setup: a local loopback client with in-memory storage. 48 49Specifically we'll be using a [confidential client](https://atproto.com/specs/oauth#types-of-clients). Our Next server will hold credentials for talking to a user's PDS. Our server will verify incoming requests from the browser using cookies for session auth. 50 51### 2.1 OAuth Client 52 53Create `lib/auth/client.ts`: 54 55```typescript 56import { 57 NodeOAuthClient, 58 buildAtprotoLoopbackClientMetadata, 59} from "@atproto/oauth-client-node"; 60import type { 61 NodeSavedSession, 62 NodeSavedState, 63} from "@atproto/oauth-client-node"; 64 65export const SCOPE = "atproto"; 66 67// Use globalThis to persist across Next.js hot reloads 68const globalAuth = globalThis as unknown as { 69 stateStore: Map<string, NodeSavedState>; 70 sessionStore: Map<string, NodeSavedSession>; 71}; 72globalAuth.stateStore ??= new Map(); 73globalAuth.sessionStore ??= new Map(); 74 75let client: NodeOAuthClient | null = null; 76 77export async function getOAuthClient(): Promise<NodeOAuthClient> { 78 if (client) return client; 79 80 client = new NodeOAuthClient({ 81 clientMetadata: buildAtprotoLoopbackClientMetadata({ 82 scope: SCOPE, 83 redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], 84 }), 85 86 stateStore: { 87 async get(key: string) { 88 return globalAuth.stateStore.get(key); 89 }, 90 async set(key: string, value: NodeSavedState) { 91 globalAuth.stateStore.set(key, value); 92 }, 93 async del(key: string) { 94 globalAuth.stateStore.delete(key); 95 }, 96 }, 97 98 sessionStore: { 99 async get(key: string) { 100 return globalAuth.sessionStore.get(key); 101 }, 102 async set(key: string, value: NodeSavedSession) { 103 globalAuth.sessionStore.set(key, value); 104 }, 105 async del(key: string) { 106 globalAuth.sessionStore.delete(key); 107 }, 108 }, 109 }); 110 111 return client; 112} 113``` 114 115The `globalThis` pattern is a standard Next.js technique for persisting data across hot module reloads in development. Without this, the in-memory stores would be wiped every time you edit a file. 116 117AT Protocol OAuth has a special carveout for local development. The `client_id` must be `localhost` and the `redirect_uri` must be on host `127.0.0.1`. Read more [here](https://atproto.com/specs/oauth#localhost-client-development). 118 119**Key concepts:** 120- `buildAtprotoLoopbackClientMetadata` - Helper for special client type used in localhost development 121- `stateStore` - Temporary storage during OAuth flow (CSRF protection) 122- `sessionStore` - Persistent sessions keyed by user's DID 123 124### 2.2 Session Helper 125 126Create `lib/auth/session.ts`: 127 128```typescript 129import { cookies } from "next/headers"; 130import { getOAuthClient } from "./client"; 131import type { OAuthSession } from "@atproto/oauth-client-node"; 132 133export async function getSession(): Promise<OAuthSession | null> { 134 const did = await getDid(); 135 if (!did) return null; 136 137 try { 138 const client = await getOAuthClient(); 139 return await client.restore(did); 140 } catch { 141 return null; 142 } 143} 144 145export async function getDid(): Promise<string | null> { 146 const cookieStore = await cookies(); 147 return cookieStore.get("did")?.value ?? null; 148} 149``` 150 151### 2.3 Login Route 152 153Create `app/oauth/login/route.ts`: 154 155This route initiates the login flow. We only need the user's handle. We'll then resolve the user's Authorization Server (their PDS) and redirect them there. 156 157```typescript 158import { NextRequest, NextResponse } from "next/server"; 159import { getOAuthClient, SCOPE } from "@/lib/auth/client"; 160 161export async function POST(request: NextRequest) { 162 try { 163 const { handle } = await request.json(); 164 165 if (!handle || typeof handle !== "string") { 166 return NextResponse.json( 167 { error: "Handle is required" }, 168 { status: 400 } 169 ); 170 } 171 172 const client = await getOAuthClient(); 173 174 // Resolves handle, finds their auth server, returns authorization URL 175 const authUrl = await client.authorize(handle, { 176 scope: SCOPE, 177 }); 178 179 return NextResponse.json({ redirectUrl: authUrl.toString() }); 180 } catch (error) { 181 console.error("OAuth login error:", error); 182 return NextResponse.json( 183 { error: error instanceof Error ? error.message : "Login failed" }, 184 { status: 500 } 185 ); 186 } 187} 188``` 189 190### 2.4 Callback Route 191 192After a user approves the authorization consent screen, they'll be redirected to this callback route. We'll exchange the code from the redirect for actual credentials. We'll then set a cookie for the user's DID. 193 194Create `app/oauth/callback/route.ts`: 195 196```typescript 197import { NextRequest, NextResponse } from "next/server"; 198import { getOAuthClient } from "@/lib/auth/client"; 199 200const PUBLIC_URL = process.env.PUBLIC_URL || "http://127.0.0.1:3000"; 201 202export async function GET(request: NextRequest) { 203 try { 204 const params = request.nextUrl.searchParams; 205 const client = await getOAuthClient(); 206 207 // Exchange code for session 208 const { session } = await client.callback(params); 209 210 const response = NextResponse.redirect(new URL("/", PUBLIC_URL)); 211 212 // Set DID cookie 213 response.cookies.set("did", session.did, { 214 httpOnly: true, 215 secure: process.env.NODE_ENV === "production", 216 sameSite: "lax", 217 maxAge: 60 * 60 * 24 * 7, // 1 week 218 path: "/", 219 }); 220 221 return response; 222 } catch (error) { 223 console.error("OAuth callback error:", error); 224 return NextResponse.redirect(new URL("/?error=login_failed", PUBLIC_URL)); 225 } 226} 227``` 228 229We use `PUBLIC_URL` with a fallback to `127.0.0.1:3000` to ensure we always redirect to the correct host. This avoids cookie issues that arise from localhost vs 127.0.0.1 mismatches. 230 231### 2.5 Logout Route 232 233To logout, we want to delete the cookie and revoke the current Oauth session for the user. 234 235Create `app/oauth/logout/route.ts`: 236 237```typescript 238import { NextResponse } from "next/server"; 239import { cookies } from "next/headers"; 240import { getOAuthClient } from "@/lib/auth/client"; 241 242export async function POST() { 243 try { 244 const cookieStore = await cookies(); 245 const did = cookieStore.get("did")?.value; 246 247 if (did) { 248 const client = await getOAuthClient(); 249 await client.revoke(did); 250 } 251 252 cookieStore.delete("did"); 253 return NextResponse.json({ success: true }); 254 } catch (error) { 255 console.error("Logout error:", error); 256 const cookieStore = await cookies(); 257 cookieStore.delete("did"); 258 return NextResponse.json({ success: true }); 259 } 260} 261``` 262 263### 2.6 Client Metadata Route 264 265This route exposes the OAuth client metadata at a well-known URL. It's useful for debugging and required for production OAuth. 266 267Create `app/oauth-client-metadata.json/route.ts`: 268 269```typescript 270import { getOAuthClient } from "@/lib/auth/client"; 271import { NextResponse } from "next/server"; 272 273// The URL of this endpoint IS your client_id 274// Authorization servers fetch this to learn about your app 275 276export async function GET() { 277 const client = await getOAuthClient(); 278 return NextResponse.json(client.clientMetadata); 279} 280``` 281 282You can visit `http://127.0.0.1:3000/oauth-client-metadata.json` to see your client configuration. 283 284### 2.7 Login Form Component 285 286Create `components/LoginForm.tsx`: 287 288```typescript 289"use client"; 290 291import { useState } from "react"; 292 293export function LoginForm() { 294 const [handle, setHandle] = useState(""); 295 const [loading, setLoading] = useState(false); 296 const [error, setError] = useState<string | null>(null); 297 298 async function handleSubmit(e: React.FormEvent) { 299 e.preventDefault(); 300 setLoading(true); 301 setError(null); 302 303 try { 304 const res = await fetch("/oauth/login", { 305 method: "POST", 306 headers: { "Content-Type": "application/json" }, 307 body: JSON.stringify({ handle }), 308 }); 309 310 const data = await res.json(); 311 312 if (!res.ok) { 313 throw new Error(data.error || "Login failed"); 314 } 315 316 // Redirect to authorization server 317 window.location.href = data.redirectUrl; 318 } catch (err) { 319 setError(err instanceof Error ? err.message : "Login failed"); 320 setLoading(false); 321 } 322 } 323 324 return ( 325 <form onSubmit={handleSubmit} className="space-y-4"> 326 <div> 327 <label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1"> 328 Handle 329 </label> 330 <input 331 type="text" 332 value={handle} 333 onChange={(e) => setHandle(e.target.value)} 334 placeholder="user.example.com" 335 className="w-full px-3 py-2 border border-zinc-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100" 336 disabled={loading} 337 /> 338 </div> 339 340 {error && <p className="text-red-500 text-sm">{error}</p>} 341 342 <button 343 type="submit" 344 disabled={loading || !handle} 345 className="w-full py-2 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50" 346 > 347 {loading ? "Signing in..." : "Sign in"} 348 </button> 349 </form> 350 ); 351} 352``` 353 354### 2.8 Logout Button Component 355 356Create `components/LogoutButton.tsx`: 357 358```typescript 359"use client"; 360 361import { useRouter } from "next/navigation"; 362 363export function LogoutButton() { 364 const router = useRouter(); 365 366 async function handleLogout() { 367 await fetch("/oauth/logout", { method: "POST" }); 368 router.refresh(); 369 } 370 371 return ( 372 <button 373 onClick={handleLogout} 374 className="text-sm text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200" 375 > 376 Sign out 377 </button> 378 ); 379} 380``` 381 382### 2.9 Update Home Page 383 384Replace `app/page.tsx`: 385 386```typescript 387import { getSession } from "@/lib/auth/session"; 388import { LoginForm } from "@/components/LoginForm"; 389import { LogoutButton } from "@/components/LogoutButton"; 390 391export default async function Home() { 392 const session = await getSession(); 393 394 return ( 395 <div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950"> 396 <main className="w-full max-w-md mx-auto p-8"> 397 <div className="text-center mb-8"> 398 <h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-2"> 399 AT Protocol OAuth 400 </h1> 401 <p className="text-zinc-600 dark:text-zinc-400"> 402 Sign in with your AT Protocol account 403 </p> 404 </div> 405 406 <div className="bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 p-6"> 407 {session ? ( 408 <div className="space-y-4"> 409 <div className="flex items-center justify-between"> 410 <p className="text-sm text-zinc-600 dark:text-zinc-400"> 411 Signed in as{" "} 412 <span className="font-mono">{session.did}</span> 413 </p> 414 <LogoutButton /> 415 </div> 416 <p className="text-green-600">Authentication working!</p> 417 </div> 418 ) : ( 419 <LoginForm /> 420 )} 421 </div> 422 </main> 423 </div> 424 ); 425} 426``` 427 428### Checkpoint: Test OAuth 429 430Your app should already be running from Part 1. If not: 431 432```bash 433pnpm dev 434``` 435 4361. Open http://127.0.0.1:3000 (_not_ localhost) 4372. Enter your handle 4383. Authorize the app 4394. You should see "Authentication working!" with your DID 440 441--- 442 443## Part 3: Add Database Persistence 444 445The in-memory approach works for local development, but for production you'll want a proper database. Let's add SQLite. 446 447### 3.1 Install Database Dependencies 448 449```bash 450pnpm add better-sqlite3 kysely 451pnpm add -D @types/better-sqlite3 tsx 452``` 453 454**What these do:** 455- `better-sqlite3` - Fast SQLite driver 456- `kysely` - Type-safe SQL query builder 457- `tsx` - Run TypeScript files directly (for scripts) 458 459### 3.2 Update next.config.ts 460 461```typescript 462import type { NextConfig } from "next"; 463 464const nextConfig: NextConfig = { 465 serverExternalPackages: ["better-sqlite3"], 466}; 467 468export default nextConfig; 469``` 470 471This tells Next.js to use the native SQLite module server-side. 472 473### 3.3 Database Connection 474 475Create `lib/db/index.ts`: 476 477```typescript 478import Database from "better-sqlite3"; 479import { Kysely, SqliteDialect } from "kysely"; 480 481const DATABASE_PATH = process.env.DATABASE_PATH || "app.db"; 482 483let _db: Kysely<DatabaseSchema> | null = null; 484 485export const getDb = (): Kysely<DatabaseSchema> => { 486 if (!_db) { 487 const sqlite = new Database(DATABASE_PATH); 488 sqlite.pragma("journal_mode = WAL"); 489 490 _db = new Kysely<DatabaseSchema>({ 491 dialect: new SqliteDialect({ database: sqlite }), 492 }); 493 } 494 return _db; 495}; 496 497export interface DatabaseSchema { 498 auth_state: AuthStateTable; 499 auth_session: AuthSessionTable; 500} 501 502interface AuthStateTable { 503 key: string; 504 value: string; 505} 506 507interface AuthSessionTable { 508 key: string; 509 value: string; 510} 511``` 512 513### 3.4 Create Migrations 514 515Create `lib/db/migrations.ts`: 516 517```typescript 518import { Kysely, Migration, Migrator } from "kysely"; 519import { getDb } from "."; 520 521const migrations: Record<string, Migration> = { 522 "001": { 523 async up(db: Kysely<unknown>) { 524 await db.schema 525 .createTable("auth_state") 526 .addColumn("key", "text", (col) => col.primaryKey()) 527 .addColumn("value", "text", (col) => col.notNull()) 528 .execute(); 529 530 await db.schema 531 .createTable("auth_session") 532 .addColumn("key", "text", (col) => col.primaryKey()) 533 .addColumn("value", "text", (col) => col.notNull()) 534 .execute(); 535 }, 536 async down(db: Kysely<unknown>) { 537 await db.schema.dropTable("auth_session").execute(); 538 await db.schema.dropTable("auth_state").execute(); 539 }, 540 }, 541}; 542 543export function getMigrator() { 544 const db = getDb(); 545 return new Migrator({ 546 db, 547 provider: { 548 getMigrations: async () => migrations, 549 }, 550 }); 551} 552``` 553 554### 3.5 Migration Script 555 556Create `scripts/migrate.ts`: 557 558```typescript 559import { getMigrator } from "@/lib/db/migrations"; 560 561async function main() { 562 const migrator = getMigrator(); 563 const { error } = await migrator.migrateToLatest(); 564 if (error) throw error; 565 console.log("Migrations complete."); 566} 567 568main(); 569``` 570 571### 3.6 Update package.json Scripts 572 573```json 574{ 575 "scripts": { 576 "dev": "pnpm migrate && next dev", 577 "build": "next build", 578 "start": "pnpm migrate && next start", 579 "migrate": "tsx scripts/migrate.ts" 580 "lint": "eslint" 581 } 582} 583``` 584 585### 3.7 Update OAuth Client to Use Database 586 587Update the following sections in `lib/auth/client.ts`: 588 589```typescript 590{ 591 stateStore: { 592 async get(key: string) { 593 const db = getDb(); 594 const row = await db 595 .selectFrom("auth_state") 596 .select("value") 597 .where("key", "=", key) 598 .executeTakeFirst(); 599 return row ? JSON.parse(row.value) : undefined; 600 }, 601 async set(key: string, value: NodeSavedState) { 602 const db = getDb(); 603 const valueJson = JSON.stringify(value); 604 await db 605 .insertInto("auth_state") 606 .values({ key, value: valueJson }) 607 .onConflict((oc) => oc.column("key").doUpdateSet({ value: valueJson })) 608 .execute(); 609 }, 610 async del(key: string) { 611 const db = getDb(); 612 await db.deleteFrom("auth_state").where("key", "=", key).execute(); 613 }, 614 }, 615 616 sessionStore: { 617 async get(key: string) { 618 const db = getDb(); 619 const row = await db 620 .selectFrom("auth_session") 621 .select("value") 622 .where("key", "=", key) 623 .executeTakeFirst(); 624 return row ? JSON.parse(row.value) : undefined; 625 }, 626 async set(key: string, value: NodeSavedSession) { 627 const db = getDb(); 628 const valueJson = JSON.stringify(value); 629 await db 630 .insertInto("auth_session") 631 .values({ key, value: valueJson }) 632 .onConflict((oc) => oc.column("key").doUpdateSet({ value: valueJson })) 633 .execute(); 634 }, 635 async del(key: string) { 636 const db = getDb(); 637 await db.deleteFrom("auth_session").where("key", "=", key).execute(); 638 }, 639 } 640} 641 642``` 643 644You can also delete the `globalAuth` memory store at the top of the file. 645 646### Checkpoint: Test with Database 647 648```bash 649pnpm dev 650``` 651 652You should see "Migrations complete." and an `app.db` file created. Now you can edit files during the OAuth flow without losing state! 653 654--- 655 656## Part 4: Production Deployment 657 658For production, you need a "confidential client" instead of the loopback client. This requires: 659- A public URL 660- A private key for signing 661- Public endpoints for client metadata and JWKS 662 663### 4.1 Environment Variables 664 665For production, you'll need: 666 667```env 668PUBLIC_URL=https://your-app.example.com 669PRIVATE_KEY={"kty":"EC","kid":"...","alg":"ES256",...} 670``` 671 672### 4.2 Generate Private Key 673 674Create `scripts/gen-key.ts`: 675 676```typescript 677import { JoseKey } from "@atproto/oauth-client-node"; 678 679async function main() { 680 const kid = Date.now().toString(); 681 const key = await JoseKey.generate(["ES256"], kid); 682 console.log(JSON.stringify(key.privateJwk)); 683}; 684 685main(); 686``` 687 688Add to scripts in `package.json`: 689 690```json 691"gen-key": "tsx scripts/gen-key.ts" 692``` 693 694Run `pnpm gen-key` and save the output as `PRIVATE_KEY` env var. 695 696### 4.3 JWKS Endpoint 697 698This is a .well-known endpoint that advertises your client's public key 699 700Create `app/.well-known/jwks.json/route.ts`: 701 702```typescript 703import { NextResponse } from "next/server"; 704import { JoseKey } from "@atproto/oauth-client-node"; 705 706// Serves the public keys for the OAuth client 707// Required for confidential clients using private_key_jwt authentication 708 709const PRIVATE_KEY = process.env.PRIVATE_KEY; 710 711export async function GET() { 712 if (!PRIVATE_KEY) { 713 return NextResponse.json({ keys: [] }); 714 } 715 716 const key = await JoseKey.fromJWK(JSON.parse(PRIVATE_KEY)); 717 return NextResponse.json({ 718 keys: [key.publicJwk], 719 }); 720} 721``` 722 723### 4.4 Update OAuth Client for Production 724 725Update `lib/auth/client.ts` the actual metadata for your confidential client: 726 727```typescript 728import { 729 JoseKey, 730 Keyset, 731 NodeOAuthClient, 732 buildAtprotoLoopbackClientMetadata, 733} from "@atproto/oauth-client-node"; 734import type { 735 NodeSavedSession, 736 NodeSavedState, 737 OAuthClientMetadataInput, 738} from "@atproto/oauth-client-node"; 739import { getDb } from "../db"; 740 741export const SCOPE = "atproto"; 742 743let client: NodeOAuthClient | null = null; 744 745const PUBLIC_URL = process.env.PUBLIC_URL; 746const PRIVATE_KEY = process.env.PRIVATE_KEY; 747 748function getClientMetadata(): OAuthClientMetadataInput { 749 if (PUBLIC_URL) { 750 return { 751 client_id: `${PUBLIC_URL}/oauth-client-metadata.json`, 752 client_name: "OAuth Tutorial", 753 client_uri: PUBLIC_URL, 754 redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 755 grant_types: ["authorization_code", "refresh_token"], 756 response_types: ["code"], 757 scope: SCOPE, 758 token_endpoint_auth_method: "private_key_jwt" as const, 759 token_endpoint_auth_signing_alg: "ES256" as const, // must match the alg in scripts/gen-key.ts 760 jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`, 761 dpop_bound_access_tokens: true, 762 }; 763 } else { 764 return buildAtprotoLoopbackClientMetadata({ 765 scope: SCOPE, 766 redirect_uris: ["http://127.0.0.1:3000/oauth/callback"], 767 }); 768 } 769} 770 771async function getKeyset(): Promise<Keyset | undefined> { 772 if (PUBLIC_URL && PRIVATE_KEY) { 773 return new Keyset([await JoseKey.fromJWK(JSON.parse(PRIVATE_KEY))]); 774 } else { 775 return undefined; 776 } 777} 778 779export async function getOAuthClient(): Promise<NodeOAuthClient> { 780 if (client) return client; 781 782 client = new NodeOAuthClient({ 783 clientMetadata: getClientMetadata(), 784 keyset: await getKeyset(), 785 ... 786``` 787 788You can read more about the client metadata doc here: https://atproto.com/specs/oauth#client-id-metadata-document 789 790### Checkpoint: Test Confidential Client 791 792To test your confidential client, you'll need to deploy it somewhere. We've included a simple deploy guide for Railway in [RAILWAY_DEPLOY.md](./RAILWAY_DEPLOY.md) 793 794--- 795 796## Part 5: Requesting Scopes (Bonus) 797 798By default, we request only the `atproto` scope. The `atproto` scope is required and offers basic authentication for an atproto identity, however it does not authorize the client to access any privileged information or perform any actions on behalf of the user. 799 800You can request more specific scopes to expand what your app can do. 801 802### 5.1 Available Scopes 803 804AT Protocol OAuth supports several scope patterns. Full documentation on OAuth scopes can be found here: https://atproto.com/specs/permission 805 806A few examples: 807 808- `atproto` - Basic authentication (required) 809- `account:email` - Access the users email (they will have the option to opt out in the consent screen) 810- `repo:com.example.record` - Write access to all `com.example.record` records 811 812### 5.2 Update the SCOPE Constant 813 814To change the requested scope, simply update the `SCOPE` constant in `lib/auth/client.ts`. It should be a space-delimited string. 815 816```typescript 817export const SCOPE = "atproto account:email repo:com.example.record"; 818``` 819 820This constant is used in three places: 821- The loopback client metadata (for local development) 822- The production client metadata 823- The login route's authorize call 824 825By centralizing it in one constant, you only need to change it in one place. 826 827### Checkpoint: Requesting Scopes 828 8291. Run your app with `pnpm dev` 8302. Open http://127.0.0.1:3000 (_not_ localhost) 8313. Go throught he authorization flow 8324. On the consent screen of your authorization server, you should see that the app is requesting read access to your email as well as access to your repository