$npx -y skills add mjunaidca/mjs-agent-skills --skill better-auth-ssoIntegrate 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.
| 1 | # Better Auth SSO Integration |
| 2 | |
| 3 | Integrate Next.js applications with Better Auth SSO using OAuth 2.1 / OIDC with PKCE flow. |
| 4 | |
| 5 | ## When to Use |
| 6 | |
| 7 | - Implementing SSO login in Next.js apps |
| 8 | - Setting up PKCE-based OAuth flow (public clients) |
| 9 | - Managing tokens in httpOnly cookies |
| 10 | - Verifying JWTs using JWKS |
| 11 | - Implementing global logout across apps |
| 12 | |
| 13 | ## Architecture Overview |
| 14 | |
| 15 | ``` |
| 16 | ┌─────────────────┐ |
| 17 | │ Better Auth SSO │ ← Central auth server |
| 18 | │ (Auth Server) │ |
| 19 | └────────┬────────┘ |
| 20 | │ |
| 21 | ┌────┴────┐ |
| 22 | ▼ ▼ |
| 23 | ┌───────┐ ┌───────┐ |
| 24 | │ App 1 │ │ App 2 │ ← Tenant apps (SSO clients) |
| 25 | └───────┘ └───────┘ |
| 26 | ``` |
| 27 | |
| 28 | ## Quick Start |
| 29 | |
| 30 | ```bash |
| 31 | # Dependencies |
| 32 | npm install jose |
| 33 | |
| 34 | # Environment |
| 35 | NEXT_PUBLIC_SSO_URL=http://localhost:3001 |
| 36 | NEXT_PUBLIC_SSO_CLIENT_ID=your-client-id |
| 37 | ``` |
| 38 | |
| 39 | ## Core Patterns |
| 40 | |
| 41 | ### 1. PKCE Authentication Client |
| 42 | |
| 43 | ```typescript |
| 44 | // lib/auth-client.ts |
| 45 | const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!; |
| 46 | const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!; |
| 47 | |
| 48 | // Base64URL encoding helper |
| 49 | function base64UrlEncode(buffer: Uint8Array): string { |
| 50 | const base64 = btoa(String.fromCharCode(...buffer)); |
| 51 | return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 52 | } |
| 53 | |
| 54 | // Generate cryptographically secure code verifier |
| 55 | export function generateCodeVerifier(): string { |
| 56 | const array = new Uint8Array(32); |
| 57 | crypto.getRandomValues(array); |
| 58 | return base64UrlEncode(array); |
| 59 | } |
| 60 | |
| 61 | // Generate SHA-256 code challenge |
| 62 | export async function generateCodeChallenge(verifier: string): Promise<string> { |
| 63 | const encoder = new TextEncoder(); |
| 64 | const data = encoder.encode(verifier); |
| 65 | const hash = await crypto.subtle.digest('SHA-256', data); |
| 66 | return base64UrlEncode(new Uint8Array(hash)); |
| 67 | } |
| 68 | |
| 69 | // Build OAuth authorization URL with PKCE |
| 70 | export async function getOAuthAuthorizationUrl( |
| 71 | callbackUrl?: string |
| 72 | ): Promise<string> { |
| 73 | const codeVerifier = generateCodeVerifier(); |
| 74 | const codeChallenge = await generateCodeChallenge(codeVerifier); |
| 75 | const state = generateCodeVerifier(); // Random state |
| 76 | |
| 77 | // Store for token exchange |
| 78 | sessionStorage.setItem('pkce_code_verifier', codeVerifier); |
| 79 | sessionStorage.setItem('oauth_state', state); |
| 80 | if (callbackUrl) { |
| 81 | sessionStorage.setItem('oauth_callback_url', callbackUrl); |
| 82 | } |
| 83 | |
| 84 | const params = new URLSearchParams({ |
| 85 | client_id: CLIENT_ID, |
| 86 | redirect_uri: `${window.location.origin}/api/auth/callback`, |
| 87 | response_type: 'code', |
| 88 | scope: 'openid profile email', |
| 89 | state, |
| 90 | code_challenge: codeChallenge, |
| 91 | code_challenge_method: 'S256', |
| 92 | }); |
| 93 | |
| 94 | return `${SSO_URL}/api/auth/oauth2/authorize?${params}`; |
| 95 | } |
| 96 | |
| 97 | // Get stored PKCE verifier |
| 98 | export function getStoredCodeVerifier(): string | null { |
| 99 | return sessionStorage.getItem('pkce_code_verifier'); |
| 100 | } |
| 101 | |
| 102 | // Clear PKCE storage |
| 103 | export function clearPKCEStorage(): void { |
| 104 | sessionStorage.removeItem('pkce_code_verifier'); |
| 105 | sessionStorage.removeItem('oauth_state'); |
| 106 | sessionStorage.removeItem('oauth_callback_url'); |
| 107 | } |
| 108 | ``` |
| 109 | |
| 110 | ### 2. OAuth Callback Route |
| 111 | |
| 112 | ```typescript |
| 113 | // app/api/auth/callback/route.ts |
| 114 | import { NextRequest, NextResponse } from 'next/server'; |
| 115 | import { cookies } from 'next/headers'; |
| 116 | |
| 117 | const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!; |
| 118 | const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!; |
| 119 | |
| 120 | export async function GET(request: NextRequest) { |
| 121 | const searchParams = request.nextUrl.searchParams; |
| 122 | const code = searchParams.get('code'); |
| 123 | const state = searchParams.get('state'); |
| 124 | const error = searchParams.get('error'); |
| 125 | |
| 126 | if (error) { |
| 127 | return NextResponse.redirect(new URL(`/login?error=${error}`, request.url)); |
| 128 | } |
| 129 | |
| 130 | if (!code) { |
| 131 | return NextResponse.redirect(new URL('/login?error=no_code', request.url)); |
| 132 | } |
| 133 | |
| 134 | // Get code verifier from cookie (set by client before redirect) |
| 135 | const cookieStore = await cookies(); |
| 136 | const codeVerifier = cookieStore.get('pkce_code_verifier')?.value; |
| 137 | |
| 138 | if (!codeVerifier) { |
| 139 | return NextResponse.redirect( |
| 140 | new URL('/login?error=no_verifier', request.url) |
| 141 | ); |
| 142 | } |
| 143 | |
| 144 | try { |
| 145 | // Exchange code for tokens (PKCE - no client secret!) |
| 146 | const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, { |
| 147 | method: 'POST', |
| 148 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 149 | body: new URLSearchParams({ |
| 150 | grant_type: 'authorization_code', |
| 151 | code, |
| 152 | redirect_uri: `${new URL(request.url).origin}/api/auth/callback`, |
| 153 | client_id: CLIENT_ID, |
| 154 | code_verifier: codeVerifier, |
| 155 | }), |
| 156 | }); |
| 157 | |
| 158 | if (!tokenResponse.ok) { |
| 159 | const error = await tokenResponse.text(); |
| 160 | console.error('Token exchange failed:', error); |
| 161 | return NextResponse.redirect( |
| 162 | new URL('/login?error=token_exchange', request.url) |
| 163 | ); |
| 164 | } |
| 165 | |
| 166 | const tokens = await tok |