Awesome-omni-skill gainforest-oauth-setup
Implement ATProto OAuth authentication in a Next.js App Router application using gainforest-sdk-nextjs. Use when adding login, logout, session management, or authentication flows that integrate with GainForest, Hypercerts, or ATProto PDSes (climateai.org, gainforest.id).
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/gainforest-oauth-setup-gainforest" ~/.claude/skills/diegosouzapw-awesome-omni-skill-gainforest-oauth-setup && rm -rf "$T"
skills/development/gainforest-oauth-setup-gainforest/SKILL.mdGainForest OAuth Implementation
Step-by-step instructions for implementing ATProto OAuth in a Next.js (App Router) application using
gainforest-sdk-nextjs.
When to Apply
Use this skill when:
- Adding OAuth/authentication to a Next.js app using
gainforest-sdk-nextjs - Setting up login/logout flows for ATProto PDS accounts
- Integrating with climateai.org or gainforest.id PDS servers
- Configuring session management with iron-session + Supabase
Prerequisites
Before starting, verify:
- The project is a Next.js App Router application
andgainforest-sdk-nextjs
are installed (if not, run@supabase/supabase-js
)npm install gainforest-sdk-nextjs @supabase/supabase-js- A Supabase project exists with two required tables:
andatproto_oauth_session
. If these tables do not exist yet, create them first using the SQL in references/supabase-tables.mdatproto_oauth_state
Critical API Rules
These are non-obvious gotchas. Violating any of these will cause runtime failures.
nesting:storage
andsessionStore
must be nested understateStore
instorage: { ... }
config. They are NOT top-level properties.createATProtoSDK()
has noOAuthSession
: The session returned byhandle
only hascallback()
/sub
. You MUST resolve the handle separately viadid
+Agent
.com.atproto.repo.describeRepo()
constructor takes 2 arguments:GainForestSDK
. Not just domains.new GainForestSDK(domains, atprotoSDK)
takes 0 arguments: The SDK instance is injected at construction time.getServerCaller()
is NOT a standalone export: UsecreateContext
instance method instead.gainforestSDK.createContext()- Logout requires two steps: Call
to invalidate tokens in Supabase, THENsdk.revokeSession(did)
to clear the cookie. SkippingclearAppSession()
leaves tokens valid.revokeSession()
must be >= 32 characters: iron-session will throw otherwise.COOKIE_SECRET- All OAuth/session helpers are server-side only: They use
fromcookies()
.next/headers
Required Environment Variables
Ensure
.env.local contains:
# Supabase NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # OAuth Client Configuration # For local development, use 127.0.0.1 (see references/local-development.md for loopback details) NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 # Private key - no "use" field (deprecated), no "key_ops" needed for private keys OAUTH_PRIVATE_KEY='{"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"key-1","alg":"ES256"}' # Session Cookie COOKIE_SECRET=your-secret-key-at-least-32-characters-long COOKIE_NAME=your_app_session
| Variable | Required | Notes |
|---|---|---|
| Yes | Your Supabase project URL |
| Yes | Server-side only. Never expose to client. |
| Yes | Public URL of your app. Use for local dev (not ). |
| Yes | ES256 JWK. Generate with scripts/generate-oauth-key.js if needed. |
| Yes | Min 32 characters. Used by iron-session for cookie encryption. |
| No | Unique per app (e.g., ). Defaults to . |
Implementation Steps
Follow these steps in order. Each step produces one file.
Step 1: Generate OAuth Private Key (if needed)
Only if the user doesn't already have an
OAUTH_PRIVATE_KEY.
Run the bundled script:
node scripts/generate-oauth-key.js
Or copy the script from scripts/generate-oauth-key.js into the user's project and run it there. The script requires
jose as a dependency (npm install jose).
Step 2: Create ATProto SDK Instance
Important: For local development loopback configuration (localhost vs 127.0.0.1, RFC 8252 requirements, scope handling), see references/local-development.md.
Create
lib/atproto.ts:
import { createATProtoSDK, createSupabaseSessionStore, createSupabaseStateStore, } from "gainforest-sdk-nextjs/oauth"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); const APP_ID = "your-app-name"; // Unique per app, e.g., "greenglobe", "bumicerts" const PUBLIC_URL = process.env.NEXT_PUBLIC_APP_URL!; const isDev = process.env.NODE_ENV === "development"; // Loopback clients require "atproto transition:generic" scope const scope = isDev ? "atproto transition:generic" : "atproto"; export const atprotoSDK = createATProtoSDK({ oauth: { // Loopback: client ID embeds scope and redirect URI (no port) // Production: client ID is URL to metadata endpoint clientId: isDev ? `http://localhost?scope=${encodeURIComponent(scope)}&redirect_uri=${encodeURIComponent(`${PUBLIC_URL}/api/oauth/callback`)}` : `${PUBLIC_URL}/client-metadata.json`, redirectUri: `${PUBLIC_URL}/api/oauth/callback`, jwksUri: `${PUBLIC_URL}/.well-known/jwks.json`, jwkPrivate: process.env.OAUTH_PRIVATE_KEY!, scope, }, servers: { pds: "https://climateai.org", // or "https://gainforest.id" }, storage: { sessionStore: createSupabaseSessionStore(supabase, APP_ID), stateStore: createSupabaseStateStore(supabase, APP_ID), }, });
Step 3: Client Metadata Route
Create
app/client-metadata.json/route.ts:
import { NextResponse } from "next/server"; const PUBLIC_URL = process.env.NEXT_PUBLIC_APP_URL!; const isDev = process.env.NODE_ENV === "development"; // Loopback clients require "atproto transition:generic" scope const scope = isDev ? "atproto transition:generic" : "atproto"; export async function GET() { const metadata = { // Loopback: client ID embeds scope and redirect URI // Production: client ID is this metadata endpoint URL client_id: isDev ? `http://localhost?scope=${encodeURIComponent(scope)}&redirect_uri=${encodeURIComponent(`${PUBLIC_URL}/api/oauth/callback`)}` : `${PUBLIC_URL}/client-metadata.json`, client_name: "Your App Name", client_uri: PUBLIC_URL, logo_uri: `${PUBLIC_URL}/logo.png`, tos_uri: `${PUBLIC_URL}/terms`, policy_uri: `${PUBLIC_URL}/privacy`, redirect_uris: [`${PUBLIC_URL}/api/oauth/callback`], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope, // Loopback uses "none", production uses "private_key_jwt" token_endpoint_auth_method: isDev ? "none" : "private_key_jwt", token_endpoint_auth_signing_alg: isDev ? undefined : "ES256", // Loopback is "native", production is "web" application_type: isDev ? "native" : "web", dpop_bound_access_tokens: true, jwks_uri: `${PUBLIC_URL}/.well-known/jwks.json`, }; return NextResponse.json(metadata, { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600", }, }); }
Step 4: JWKS Endpoint
Create
app/.well-known/jwks.json/route.ts:
import { NextResponse } from "next/server"; export async function GET() { const privateKey = JSON.parse(process.env.OAUTH_PRIVATE_KEY!); const { d, ...publicKey } = privateKey; // Add key_ops for public key verification (replaces deprecated "use" field) const jwk = { ...publicKey, key_ops: ["verify"], }; // Remove deprecated "use" field if present in source key delete jwk.use; return NextResponse.json({ keys: [jwk] }, { headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=3600", }, }); }
Note: The
key_ops: ["verify"] field replaces the deprecated use: "sig" field per current JWK specifications. Only public keys in the JWKS endpoint need key_ops; private keys do not.
Step 5: Authorization Route
Create
app/api/oauth/authorize/route.ts (or implement as a server action):
import { NextRequest, NextResponse } from "next/server"; import { atprotoSDK } from "@/lib/atproto"; export async function POST(request: NextRequest) { try { const { handle } = await request.json(); if (!handle) { return NextResponse.json( { error: "Handle is required" }, { status: 400 } ); } const authUrl = await atprotoSDK.authorize(handle); return NextResponse.json({ authorizationUrl: authUrl.toString() }); } catch (error) { console.error("Authorization error:", error); return NextResponse.json( { error: "Failed to initiate authorization" }, { status: 500 } ); } }
Step 6: Callback Route
Create
app/api/oauth/callback/route.ts:
IMPORTANT:
OAuthSession does NOT have a handle property. You must resolve it from the DID using an Agent.
import { NextRequest } from "next/server"; import { redirect } from "next/navigation"; import { atprotoSDK } from "@/lib/atproto"; import { saveAppSession, Agent } from "gainforest-sdk-nextjs/oauth"; export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; const oauthSession = await atprotoSDK.callback(searchParams); // Resolve handle from DID -- OAuthSession only has sub/did, NOT handle const agent = new Agent(oauthSession); const { data: profile } = await agent.com.atproto.repo.describeRepo({ repo: oauthSession.did, }); await saveAppSession({ did: oauthSession.did, handle: profile.handle, isLoggedIn: true, }); redirect("/dashboard"); } catch (error) { console.error("OAuth callback error:", error); redirect("/login?error=auth_failed"); } }
Step 7: Logout Route
Create
app/api/oauth/logout/route.ts (or implement as a server action):
IMPORTANT: Must call
revokeSession() before clearAppSession(). Otherwise OAuth tokens remain valid in Supabase.
import { NextResponse } from "next/server"; import { clearAppSession, getAppSession } from "gainforest-sdk-nextjs/oauth"; import { atprotoSDK } from "@/lib/atproto"; export async function POST() { try { const appSession = await getAppSession(); if (appSession.did) { await atprotoSDK.revokeSession(appSession.did); } await clearAppSession(); return NextResponse.json({ success: true }); } catch (error) { console.error("Logout error:", error); return NextResponse.json( { error: "Failed to logout" }, { status: 500 } ); } }
Step 8: Session Check Route
Create
app/api/oauth/session/route.ts:
import { NextResponse } from "next/server"; import { getAppSession } from "gainforest-sdk-nextjs/oauth"; import { atprotoSDK } from "@/lib/atproto"; export async function GET() { try { const appSession = await getAppSession(); if (!appSession.isLoggedIn || !appSession.did) { return NextResponse.json({ authenticated: false }); } const oauthSession = await atprotoSDK.restoreSession(appSession.did); if (!oauthSession) { return NextResponse.json({ authenticated: false }); } return NextResponse.json({ authenticated: true, did: appSession.did, handle: appSession.handle, }); } catch (error) { console.error("Session check error:", error); return NextResponse.json({ authenticated: false }); } }
Step 9: Login UI Component
Create a client component (e.g.,
components/login-form.tsx):
"use client"; import { useState } from "react"; export function LoginForm() { const [handle, setHandle] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(""); try { const response = await fetch("/api/oauth/authorize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ handle }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || "Authorization failed"); } window.location.href = data.authorizationUrl; } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); setLoading(false); } }; return ( <form onSubmit={handleSubmit}> <div> <label htmlFor="handle">Your Handle</label> <input id="handle" type="text" value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="username.climateai.org" required /> </div> {error && <p style={{ color: "red" }}>{error}</p>} <button type="submit" disabled={loading}> {loading ? "Redirecting..." : "Sign in with ATProto"} </button> </form> ); }
Step 10 (Optional): tRPC Integration
If the app uses the SDK's built-in tRPC routers:
:lib/trpc.ts
import { GainForestSDK } from "gainforest-sdk-nextjs"; import { atprotoSDK } from "@/lib/atproto"; // Two arguments: domains array AND the atprotoSDK instance const gainforestSDK = new GainForestSDK( ["climateai.org", "gainforest.id"], atprotoSDK ); // Zero arguments -- SDK already injected at construction export const serverCaller = gainforestSDK.getServerCaller();
:app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { GainForestSDK } from "gainforest-sdk-nextjs"; import { atprotoSDK } from "@/lib/atproto"; const gainforestSDK = new GainForestSDK( ["climateai.org", "gainforest.id"], atprotoSDK ); const handler = (req: Request) => fetchRequestHandler({ endpoint: "/api/trpc", req, router: gainforestSDK.appRouter, // Instance method -- createContext is NOT a standalone export createContext: () => gainforestSDK.createContext({ req }), }); export { handler as GET, handler as POST };
Making Authenticated API Calls
After OAuth is set up, use this pattern for authenticated server-side calls:
import { atprotoSDK } from "@/lib/atproto"; import { getAppSession, Agent } from "gainforest-sdk-nextjs/oauth"; export async function getAuthenticatedAgent(): Promise<Agent> { const appSession = await getAppSession(); if (!appSession.isLoggedIn || !appSession.did) { throw new Error("Not authenticated"); } const oauthSession = await atprotoSDK.restoreSession(appSession.did); if (!oauthSession) { throw new Error("Session expired"); } return new Agent(oauthSession); }
Import Reference
| Import Path | Exports |
|---|---|
| |
| , , , , , , , , , , , , |
| , , , |
| (tRPC client) |
Expected File Structure
After implementation, the app should have:
your-app/ ├── .env.local ├── lib/ │ ├── atproto.ts # SDK instance │ └── trpc.ts # tRPC setup (optional) ├── app/ │ ├── client-metadata.json/ │ │ └── route.ts # OAuth client metadata │ ├── .well-known/ │ │ └── jwks.json/ │ │ └── route.ts # Public JWKS endpoint │ ├── api/ │ │ ├── oauth/ │ │ │ ├── authorize/ │ │ │ │ └── route.ts # Initiate OAuth flow │ │ │ ├── callback/ │ │ │ │ └── route.ts # Handle OAuth callback │ │ │ ├── logout/ │ │ │ │ └── route.ts # Revoke session + clear cookie │ │ │ └── session/ │ │ │ └── route.ts # Check session status │ │ └── trpc/ │ │ └── [trpc]/ │ │ └── route.ts # tRPC handler (optional) │ └── login/ │ └── page.tsx # Login page └── components/ └── login-form.tsx # Login form component
Further Reading
- Supabase table setup -- SQL DDL for the required tables
- Local development -- Loopback URLs, dev mode, env overrides
- Troubleshooting -- Common errors, security checklist, cleanup