Claude Code Plugins

Community-maintained marketplace

Feedback

Integrate with Better Auth SSO for OAuth2/OIDC authentication. Use this skill when implementing SSO login flows, PKCE authentication, token management, JWKS verification, or global logout in Next.js applications connecting to a Better Auth server.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name better-auth-sso
description Integrate with Better Auth SSO for OAuth2/OIDC authentication. Use this skill when implementing SSO login flows, PKCE authentication, token management, JWKS verification, or global logout in Next.js applications connecting to a Better Auth server.

Better Auth SSO Integration

Integrate Next.js applications with Better Auth SSO using OAuth 2.1 / OIDC with PKCE flow.

When to Use

  • Implementing SSO login in Next.js apps
  • Setting up PKCE-based OAuth flow (public clients)
  • Managing tokens in httpOnly cookies
  • Verifying JWTs using JWKS
  • Implementing global logout across apps

Architecture Overview

┌─────────────────┐
│ Better Auth SSO │ ← Central auth server
│  (Auth Server)  │
└────────┬────────┘
         │
    ┌────┴────┐
    ▼         ▼
┌───────┐  ┌───────┐
│ App 1 │  │ App 2 │ ← Tenant apps (SSO clients)
└───────┘  └───────┘

Quick Start

# Dependencies
npm install jose

# Environment
NEXT_PUBLIC_SSO_URL=http://localhost:3001
NEXT_PUBLIC_SSO_CLIENT_ID=your-client-id

Core Patterns

1. PKCE Authentication Client

// lib/auth-client.ts
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!;

// Base64URL encoding helper
function base64UrlEncode(buffer: Uint8Array): string {
  const base64 = btoa(String.fromCharCode(...buffer));
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

// Generate cryptographically secure code verifier
export function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

// Generate SHA-256 code challenge
export async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

// Build OAuth authorization URL with PKCE
export async function getOAuthAuthorizationUrl(
  callbackUrl?: string
): Promise<string> {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = generateCodeVerifier(); // Random state

  // Store for token exchange
  sessionStorage.setItem('pkce_code_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);
  if (callbackUrl) {
    sessionStorage.setItem('oauth_callback_url', callbackUrl);
  }

  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: `${window.location.origin}/api/auth/callback`,
    response_type: 'code',
    scope: 'openid profile email',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  return `${SSO_URL}/api/auth/oauth2/authorize?${params}`;
}

// Get stored PKCE verifier
export function getStoredCodeVerifier(): string | null {
  return sessionStorage.getItem('pkce_code_verifier');
}

// Clear PKCE storage
export function clearPKCEStorage(): void {
  sessionStorage.removeItem('pkce_code_verifier');
  sessionStorage.removeItem('oauth_state');
  sessionStorage.removeItem('oauth_callback_url');
}

2. OAuth Callback Route

// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!;

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get('code');
  const state = searchParams.get('state');
  const error = searchParams.get('error');

  if (error) {
    return NextResponse.redirect(new URL(`/login?error=${error}`, request.url));
  }

  if (!code) {
    return NextResponse.redirect(new URL('/login?error=no_code', request.url));
  }

  // Get code verifier from cookie (set by client before redirect)
  const cookieStore = await cookies();
  const codeVerifier = cookieStore.get('pkce_code_verifier')?.value;

  if (!codeVerifier) {
    return NextResponse.redirect(
      new URL('/login?error=no_verifier', request.url)
    );
  }

  try {
    // Exchange code for tokens (PKCE - no client secret!)
    const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: `${new URL(request.url).origin}/api/auth/callback`,
        client_id: CLIENT_ID,
        code_verifier: codeVerifier,
      }),
    });

    if (!tokenResponse.ok) {
      const error = await tokenResponse.text();
      console.error('Token exchange failed:', error);
      return NextResponse.redirect(
        new URL('/login?error=token_exchange', request.url)
      );
    }

    const tokens = await tokenResponse.json();

    // Create response with redirect
    const callbackUrl = cookieStore.get('oauth_callback_url')?.value || '/dashboard';
    const response = NextResponse.redirect(new URL(callbackUrl, request.url));

    // Set httpOnly cookies for tokens
    const cookieOptions = {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax' as const,
      path: '/',
    };

    response.cookies.set('access_token', tokens.access_token, {
      ...cookieOptions,
      maxAge: 60 * 60 * 6, // 6 hours
    });

    response.cookies.set('refresh_token', tokens.refresh_token, {
      ...cookieOptions,
      maxAge: 60 * 60 * 24 * 7, // 7 days
    });

    response.cookies.set('id_token', tokens.id_token, {
      ...cookieOptions,
      maxAge: 60 * 60 * 6, // 6 hours
    });

    // Clear PKCE cookies
    response.cookies.delete('pkce_code_verifier');
    response.cookies.delete('oauth_state');
    response.cookies.delete('oauth_callback_url');

    return response;
  } catch (error) {
    console.error('Callback error:', error);
    return NextResponse.redirect(
      new URL('/login?error=callback_failed', request.url)
    );
  }
}

3. Session Route

// app/api/auth/session/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify, createRemoteJWKSet } from 'jose';

const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const JWKS = createRemoteJWKSet(new URL(`${SSO_URL}/api/auth/jwks`));

export async function GET() {
  const cookieStore = await cookies();
  const idToken = cookieStore.get('id_token')?.value;

  if (!idToken) {
    return NextResponse.json({ user: null });
  }

  try {
    const { payload } = await jwtVerify(idToken, JWKS, {
      algorithms: ['RS256'],
    });

    return NextResponse.json({
      user: {
        id: payload.sub,
        email: payload.email,
        name: payload.name,
        role: payload.role || 'user',
        emailVerified: payload.email_verified,
      },
      expires: new Date((payload.exp as number) * 1000).toISOString(),
    });
  } catch (error) {
    // Token invalid or expired
    return NextResponse.json({ user: null });
  }
}

4. Auth Context Provider

// contexts/AuthContext.tsx
'use client';

import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { getOAuthAuthorizationUrl, clearPKCEStorage } from '@/lib/auth-client';

interface User {
  id: string;
  email: string;
  name: string;
  role: string;
  emailVerified: boolean;
}

interface Session {
  user: User | null;
  expires?: string;
}

interface AuthContextValue {
  session: Session | null;
  status: 'loading' | 'authenticated' | 'unauthenticated';
  signIn: (options?: { callbackUrl?: string }) => Promise<void>;
  signOut: (options?: { redirect?: boolean }) => Promise<void>;
  update: () => Promise<void>;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [session, setSession] = useState<Session | null>(null);
  const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');

  const fetchSession = useCallback(async () => {
    try {
      const response = await fetch('/api/auth/session');
      const data = await response.json();

      if (data.user) {
        setSession(data);
        setStatus('authenticated');
      } else {
        setSession(null);
        setStatus('unauthenticated');
      }
    } catch (error) {
      console.error('Failed to fetch session:', error);
      setSession(null);
      setStatus('unauthenticated');
    }
  }, []);

  useEffect(() => {
    fetchSession();
  }, [fetchSession]);

  const signIn = async (options?: { callbackUrl?: string }) => {
    const callbackUrl = options?.callbackUrl || window.location.pathname;

    // Store callback URL in cookie for server-side access
    document.cookie = `oauth_callback_url=${encodeURIComponent(callbackUrl)}; path=/; max-age=300`;

    const authUrl = await getOAuthAuthorizationUrl(callbackUrl);

    // Store code verifier in cookie for server-side access
    const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
    if (codeVerifier) {
      document.cookie = `pkce_code_verifier=${codeVerifier}; path=/; max-age=300`;
    }

    window.location.href = authUrl;
  };

  const signOut = async (options?: { redirect?: boolean }) => {
    const { redirect = true } = options || {};

    try {
      const response = await fetch('/api/auth/signout', {
        method: 'POST',
        credentials: 'include',
      });

      const data = await response.json();
      clearPKCEStorage();

      if (redirect && data.redirectUrl) {
        window.location.href = data.redirectUrl;
      } else {
        setSession(null);
        setStatus('unauthenticated');
      }
    } catch (error) {
      console.error('Sign out error:', error);
      if (redirect) {
        window.location.href = '/';
      }
    }
  };

  return (
    <AuthContext.Provider value={{ session, status, signIn, signOut, update: fetchSession }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useSession() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useSession must be used within AuthProvider');
  }
  return {
    data: context.session,
    status: context.status,
    update: context.update,
  };
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

5. Global Logout

// app/api/auth/signout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL;

export async function POST(request: NextRequest) {
  const cookieStore = await cookies();
  const idToken = cookieStore.get('id_token')?.value;

  // Build SSO logout URL
  const appUrl = new URL(request.url).origin;
  const postLogoutRedirectUri = `${appUrl}/logged-out`;

  let redirectUrl = '/logged-out';

  if (SSO_URL) {
    const endsessionUrl = new URL('/api/auth/oauth2/endsession', SSO_URL);
    endsessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
    if (idToken) {
      endsessionUrl.searchParams.set('id_token_hint', idToken);
    }
    redirectUrl = endsessionUrl.toString();
  }

  const response = NextResponse.json({ success: true, redirectUrl });

  // Clear all auth cookies
  response.cookies.delete('access_token');
  response.cookies.delete('refresh_token');
  response.cookies.delete('id_token');

  return response;
}

6. Proxy Protection (Next.js 16)

// proxy.ts (Next.js 16 - replaces middleware.ts)
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify, createRemoteJWKSet } from 'jose';

const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const JWKS = createRemoteJWKSet(new URL(`${SSO_URL}/api/auth/jwks`));

const publicPaths = ['/', '/login', '/register', '/api/auth', '/logged-out'];

async function isTokenValid(token: string): Promise<boolean> {
  try {
    await jwtVerify(token, JWKS, { algorithms: ['RS256'] });
    return true;
  } catch {
    return false;
  }
}

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Allow public paths
  if (publicPaths.some(p => pathname === p || pathname.startsWith(`${p}/`))) {
    return NextResponse.next();
  }

  // Allow static assets
  if (pathname.startsWith('/_next') || pathname.includes('.')) {
    return NextResponse.next();
  }

  // Check for valid token
  const idToken = request.cookies.get('id_token')?.value;
  const refreshToken = request.cookies.get('refresh_token')?.value;

  if (!idToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  const isValid = await isTokenValid(idToken);

  if (!isValid && refreshToken) {
    // Redirect to refresh endpoint
    const refreshUrl = new URL('/api/auth/refresh', request.url);
    refreshUrl.searchParams.set('returnTo', pathname);
    return NextResponse.redirect(refreshUrl);
  }

  if (!isValid) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

7. Token Refresh

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!;

export async function GET(request: NextRequest) {
  const returnTo = request.nextUrl.searchParams.get('returnTo') || '/dashboard';
  const cookieStore = await cookies();
  const refreshToken = cookieStore.get('refresh_token')?.value;

  if (!refreshToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      }),
    });

    if (!tokenResponse.ok) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    const tokens = await tokenResponse.json();
    const response = NextResponse.redirect(new URL(returnTo, request.url));

    const cookieOptions = {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax' as const,
      path: '/',
    };

    response.cookies.set('access_token', tokens.access_token, {
      ...cookieOptions,
      maxAge: 60 * 60 * 6,
    });

    if (tokens.id_token) {
      response.cookies.set('id_token', tokens.id_token, {
        ...cookieOptions,
        maxAge: 60 * 60 * 6,
      });
    }

    if (tokens.refresh_token) {
      response.cookies.set('refresh_token', tokens.refresh_token, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 7,
      });
    }

    return response;
  } catch (error) {
    console.error('Token refresh failed:', error);
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export async function POST() {
  const cookieStore = await cookies();
  const refreshToken = cookieStore.get('refresh_token')?.value;

  if (!refreshToken) {
    return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
  }

  try {
    const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      }),
    });

    if (!tokenResponse.ok) {
      return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
    }

    const tokens = await tokenResponse.json();
    const response = NextResponse.json({ success: true });

    // Update cookies with new tokens
    // ... same cookie setting logic

    return response;
  } catch (error) {
    return NextResponse.json({ error: 'Refresh error' }, { status: 500 });
  }
}

Environment Variables

# Public (exposed to client)
NEXT_PUBLIC_SSO_URL=http://localhost:3001
NEXT_PUBLIC_SSO_CLIENT_ID=your-client-id

# Private (server-side only)
SSO_JWKS_URL=http://localhost:3001/api/auth/jwks

Security Checklist

  • PKCE uses crypto.getRandomValues() for code verifier
  • State parameter is random and verified
  • Cookies are httpOnly, Secure (prod), SameSite=Lax
  • No tokens in localStorage
  • No tokens exposed in API responses
  • Token refresh is server-side only
  • Global logout clears SSO session
  • Error messages don't leak secrets

Common Pitfalls

1. "code verification failed" Error

Cause: PKCE code_verifier lost during sign-in redirect Fix: Store in cookie before redirect, not just sessionStorage

2. Auto-Login After Logout

Cause: Only cleared local tokens, not SSO session Fix: Redirect to SSO /endsession endpoint

3. Token Not Refreshing

Cause: Refresh happening client-side (blocked by httpOnly) Fix: Use server-side API route for refresh

References

For backend integration, see:

  • fastapi-backend/references/jwt-verification.md
  • fastapi-backend/references/better-auth-sso-integration.md

Better Auth docs: https://www.better-auth.com/docs