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).

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "gainforest-oauth-setup" with this command: npx skills add gainforest/agent-skills/gainforest-agent-skills-gainforest-oauth-setup

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

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:

# 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
VariableRequiredNotes
NEXT_PUBLIC_SUPABASE_URLYesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYYesServer-side only. Never expose to client.
NEXT_PUBLIC_APP_URLYesPublic URL of your app. Use http://127.0.0.1:3000 for local dev (not localhost).
OAUTH_PRIVATE_KEYYesES256 JWK. Generate with scripts/generate-oauth-key.js if needed.
COOKIE_SECRETYesMin 32 characters. Used by iron-session for cookie encryption.
COOKIE_NAMENoUnique 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:

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 PathExports
gainforest-sdk-nextjsGainForestSDK
gainforest-sdk-nextjs/oauthcreateATProtoSDK, createSupabaseSessionStore, createSupabaseStateStore, cleanupExpiredStates, getAppSession, saveAppSession, clearAppSession, Agent, HypercertsATProtoSDK, SessionStore, StateStore, ATProtoSDKConfig, AppSessionData
gainforest-sdk-nextjs/sessiongetAppSession, saveAppSession, clearAppSession, AppSessionData
gainforest-sdk-nextjs/clientcreateTRPCClient (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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Automation

gainforest-beads

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

Planning with files

Implements Manus-style file-based planning to organize and track progress on complex tasks. Creates task_plan.md, findings.md, and progress.md. Use when aske...

Registry SourceRecently Updated
8.4K22Profile unavailable
Coding

Nutrient Document Processing (Universal Agent Skill)

Universal (non-OpenClaw) Nutrient DWS document-processing skill for Agent Skills-compatible products. Best for Claude Code, Codex CLI, Gemini CLI, Cursor, Wi...

Registry SourceRecently Updated
2740Profile unavailable
Coding

vercel-react-best-practices

React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.

Repository Source
214.5K23Kvercel
gainforest-oauth-setup | V50.AI