Claude-skill-registry better-auth-sso
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
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/better-auth-sso" ~/.claude/skills/majiayu000-claude-skill-registry-better-auth-sso && rm -rf "$T"
manifest:
skills/data/better-auth-sso/SKILL.mdsource content
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
for code verifiercrypto.getRandomValues() - 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.mdfastapi-backend/references/better-auth-sso-integration.md
Better Auth docs: https://www.better-auth.com/docs