Meal planning and recipes sorted
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