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).
Best use case
gainforest-oauth-setup is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
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).
Teams using gainforest-oauth-setup should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/gainforest-oauth-setup/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How gainforest-oauth-setup Compares
| Feature / Agent | gainforest-oauth-setup | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
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).
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# GainForest 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
- `gainforest-sdk-nextjs` and `@supabase/supabase-js` are installed (if not, run `npm install gainforest-sdk-nextjs @supabase/supabase-js`)
- A Supabase project exists with two required tables: `atproto_oauth_session` and `atproto_oauth_state`. **If these tables do not exist yet, create them first** using the SQL in [references/supabase-tables.md](references/supabase-tables.md)
## Critical API Rules
These are non-obvious gotchas. Violating any of these will cause runtime failures.
1. **`storage` nesting**: `sessionStore` and `stateStore` must be nested under `storage: { ... }` in `createATProtoSDK()` config. They are NOT top-level properties.
2. **`OAuthSession` has no `handle`**: The session returned by `callback()` only has `sub`/`did`. You MUST resolve the handle separately via `Agent` + `com.atproto.repo.describeRepo()`.
3. **`GainForestSDK` constructor takes 2 arguments**: `new GainForestSDK(domains, atprotoSDK)`. Not just domains.
4. **`getServerCaller()` takes 0 arguments**: The SDK instance is injected at construction time.
5. **`createContext` is NOT a standalone export**: Use `gainforestSDK.createContext()` instance method instead.
6. **Logout requires two steps**: Call `sdk.revokeSession(did)` to invalidate tokens in Supabase, THEN `clearAppSession()` to clear the cookie. Skipping `revokeSession()` leaves tokens valid.
7. **`COOKIE_SECRET` must be >= 32 characters**: iron-session will throw otherwise.
8. **All OAuth/session helpers are server-side only**: They use `cookies()` from `next/headers`.
## Required Environment Variables
Ensure `.env.local` contains:
```env
# 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 |
|---|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Your Supabase project URL |
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Server-side only. Never expose to client. |
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL of your app. Use `http://127.0.0.1:3000` for local dev (not `localhost`). |
| `OAUTH_PRIVATE_KEY` | Yes | ES256 JWK. Generate with [scripts/generate-oauth-key.js](scripts/generate-oauth-key.js) if needed. |
| `COOKIE_SECRET` | Yes | Min 32 characters. Used by iron-session for cookie encryption. |
| `COOKIE_NAME` | No | Unique per app (e.g., `greenglobe_session`). Defaults to `climateai_session`. |
## 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:
```bash
node scripts/generate-oauth-key.js
```
Or copy the script from [scripts/generate-oauth-key.js](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](references/local-development.md).
Create `lib/atproto.ts`:
```typescript
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`:
```typescript
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`:
```typescript
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):
```typescript
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`.
```typescript
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.
```typescript
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`:
```typescript
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`):
```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`**:
```typescript
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`**:
```typescript
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:
```typescript
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 |
|---|---|
| `gainforest-sdk-nextjs` | `GainForestSDK` |
| `gainforest-sdk-nextjs/oauth` | `createATProtoSDK`, `createSupabaseSessionStore`, `createSupabaseStateStore`, `cleanupExpiredStates`, `getAppSession`, `saveAppSession`, `clearAppSession`, `Agent`, `HypercertsATProtoSDK`, `SessionStore`, `StateStore`, `ATProtoSDKConfig`, `AppSessionData` |
| `gainforest-sdk-nextjs/session` | `getAppSession`, `saveAppSession`, `clearAppSession`, `AppSessionData` |
| `gainforest-sdk-nextjs/client` | `createTRPCClient` (tRPC client) |
## Expected File Structure
After implementation, the app should have:
```text
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](references/supabase-tables.md) -- SQL DDL for the required tables
- [Local development](references/local-development.md) -- Loopback URLs, dev mode, env overrides
- [Troubleshooting](references/troubleshooting.md) -- Common errors, security checklist, cleanupRelated Skills
ln-774-healthcheck-setup
Configures health check endpoints for Kubernetes readiness/liveness/startup
flowglad-setup
Install and configure the Flowglad SDK for Next.js, Express, and React applications. Use this skill when adding billing to an app, setting up Flowglad for the first time, or configuring SDK providers and route handlers.
data-client-setup
Install and set up @data-client/react or @data-client/vue in a project. Detects project type (NextJS, Expo, React Native, Vue, plain React) and protocol (REST, GraphQL, custom), then hands off to protocol-specific setup skills.
conda-env-setup
This skill should be used when the user asks to "setup conda environment", "configure Python environment", "activate conda automatically", "set conda environment for workspace", or mentions conda environment activation for Claude Code. Provides automatic conda environment configuration for workspaces.
clerk-setup
Add Clerk authentication to any project by following the official quickstart guides.
ccw-maven-setup
Prepares Maven build environment for Claude Code Web by installing Java 25 and configuring Maven proxy. Run automatically before Maven operations in CCW.
bronze-layer-setup
End-to-end Bronze layer creation for testing and demos. Creates table DDLs, generates fake data with Faker, copies from existing sources, and configures Asset Bundle jobs. Covers Unity Catalog compliance, Change Data Feed, automatic liquid clustering, and governance metadata. Use when setting up Bronze layer tables, creating test/demo data, rapid prototyping Medallion Architecture, or bootstrapping a new Databricks project. For Faker-specific patterns (corruption rates, function signatures, provider examples), load the faker-data-generation skill.
bknd-client-setup
Use when setting up Bknd SDK in a frontend application. Covers Api class initialization, token storage, auth state handling, React integration with BkndBrowserApp and useApp hook, framework-specific setup (Vite, Next.js, standalone), and TypeScript type registration.
astro-setup
Astro project initialization and configuration patterns. Use when setting up new Astro projects or configuring Astro features.
angular-app-setup
Creates an Angular 20 app directly in the current folder with strict defaults, deterministic non-interactive flags, and preflight safety checks. Use when the user asks to create, scaffold, or initialize Angular 20 in place and wants build/test verification.
ai-sdk-setup
Install the Vercel AI SDK with AI Elements components. Build a streaming chat interface with the useChat hook.
setup-design-system
Initialize the design system or create new UI components with accessibility, Tailwind/shadcn integration, and documentation. Use when setting up the initial design system, adding component categories, or creating complex UI components that need design review.