Skip to main content
OAuth authentication allows users to sign in using their existing accounts from popular providers like Google, GitHub, Microsoft, and more.

Overview

Arraf Auth implements OAuth 2.0 with PKCE (Proof Key for Code Exchange) for enhanced security. The OAuth flow:
  1. User clicks “Sign in with Google”
  2. Redirect to provider’s authorization page
  3. User grants permission
  4. Provider redirects back with authorization code
  5. Exchange code for access token
  6. Fetch user profile
  7. Create/login user

Supported Providers

Arraf Auth supports any OAuth 2.0 provider. Built-in examples include:
  • Google
  • GitHub
  • Microsoft
  • Apple
  • Custom OAuth providers

Setup

1
Create OAuth App
2
First, register your application with the OAuth provider:
3
Google
  1. Go to Google Cloud Console
  2. Create a new project or select existing
  3. Navigate to APIs & Services > Credentials
  4. Click Create Credentials > OAuth client ID
  5. Select Web application
  6. Add authorized redirect URI:
    http://localhost:3000/api/auth/callback/google
    
  7. Save the Client ID and Client Secret
GitHub
  1. Go to GitHub Developer Settings
  2. Click New OAuth App
  3. Fill in application details:
    • Application name: Your App Name
    • Homepage URL: http://localhost:3000
    • Authorization callback URL:
      http://localhost:3000/api/auth/callback/github
      
  4. Click Register application
  5. Save the Client ID and generate a Client Secret
4
Configure Environment Variables
5
# .env.local
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
6
Create OAuth Providers
7
import { createAuth, OAuthProvider } from '@arraf-auth/core'
import { buildAuthorizationUrl, exchangeCodeForTokens } from '@arraf-auth/core'

// Google OAuth Provider
const googleProvider: OAuthProvider = {
  id: 'google',
  name: 'Google',
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  scopes: ['openid', 'email', 'profile'],
  
  getAuthorizationUrl(state, codeChallenge) {
    return buildAuthorizationUrl('https://accounts.google.com/o/oauth2/v2/auth', {
      client_id: this.clientId,
      redirect_uri: `${process.env.NEXT_PUBLIC_URL}/api/auth/callback/google`,
      response_type: 'code',
      scope: this.scopes.join(' '),
      state,
      code_challenge: codeChallenge!,
      code_challenge_method: 'S256',
      access_type: 'offline',
      prompt: 'consent'
    })
  },
  
  async exchangeCode(code, codeVerifier) {
    const response = await exchangeCodeForTokens(
      'https://oauth2.googleapis.com/token',
      {
        client_id: this.clientId,
        client_secret: this.clientSecret,
        code,
        code_verifier: codeVerifier!,
        grant_type: 'authorization_code',
        redirect_uri: `${process.env.NEXT_PUBLIC_URL}/api/auth/callback/google`
      }
    )
    
    const data = await response.json()
    
    return {
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
      expiresIn: data.expires_in,
      tokenType: data.token_type
    }
  },
  
  async getUserProfile(accessToken) {
    const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
      headers: { Authorization: `Bearer ${accessToken}` }
    })
    
    const data = await response.json()
    
    return {
      id: data.id,
      email: data.email,
      name: data.name,
      image: data.picture,
      emailVerified: data.verified_email
    }
  }
}

// GitHub OAuth Provider
const githubProvider: OAuthProvider = {
  id: 'github',
  name: 'GitHub',
  clientId: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  scopes: ['user:email'],
  
  getAuthorizationUrl(state) {
    return buildAuthorizationUrl('https://github.com/login/oauth/authorize', {
      client_id: this.clientId,
      redirect_uri: `${process.env.NEXT_PUBLIC_URL}/api/auth/callback/github`,
      scope: this.scopes.join(' '),
      state,
      allow_signup: 'true'
    })
  },
  
  async exchangeCode(code) {
    const response = await exchangeCodeForTokens(
      'https://github.com/login/oauth/access_token',
      {
        client_id: this.clientId,
        client_secret: this.clientSecret,
        code
      }
    )
    
    const data = await response.json()
    
    return {
      accessToken: data.access_token,
      tokenType: data.token_type,
      refreshToken: data.refresh_token,
      expiresIn: data.expires_in
    }
  },
  
  async getUserProfile(accessToken) {
    const [userRes, emailsRes] = await Promise.all([
      fetch('https://api.github.com/user', {
        headers: { Authorization: `Bearer ${accessToken}` }
      }),
      fetch('https://api.github.com/user/emails', {
        headers: { Authorization: `Bearer ${accessToken}` }
      })
    ])
    
    const user = await userRes.json()
    const emails = await emailsRes.json()
    const primaryEmail = emails.find((e: any) => e.primary)
    
    return {
      id: String(user.id),
      email: primaryEmail?.email || user.email,
      name: user.name || user.login,
      image: user.avatar_url,
      emailVerified: primaryEmail?.verified || false
    }
  }
}
8
Initialize Auth with Providers
9
import { createAuth } from '@arraf-auth/core'
import { prismaAdapter } from '@arraf-auth/adapter-prisma'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

const auth = createAuth({
  secret: process.env.AUTH_SECRET!,
  database: prismaAdapter(prisma),
  providers: [googleProvider, githubProvider],
  trustedOrigins: [process.env.NEXT_PUBLIC_URL!]
})

export { auth }
10
Add OAuth Routes
11
Create dynamic routes for OAuth flow:
12
// app/api/auth/[provider]/route.ts
import { auth } from '@/lib/auth'

export async function GET(
  req: Request,
  { params }: { params: { provider: string } }
) {
  return auth.handler(`/auth/${params.provider}`, req)
}

// app/api/auth/callback/[provider]/route.ts
export async function GET(
  req: Request,
  { params }: { params: { provider: string } }
) {
  return auth.handler(`/auth/callback/${params.provider}`, req)
}

OAuth Flow

Starting OAuth Flow

Redirect users to the provider’s authorization page:
// Client-side
function signInWithGoogle() {
  window.location.href = '/api/auth/google'
}

function signInWithGitHub() {
  window.location.href = '/api/auth/github'
}
The server generates a state token and code verifier for PKCE, stores them in cookies, and redirects to the provider.

OAuth Callback

After the user grants permission, the provider redirects to your callback URL with:
  • code: Authorization code
  • state: State token (for CSRF protection)
The callback handler (see oauth-callback.ts:15-131):
  1. Validates the state token
  2. Exchanges code for access token using PKCE verifier
  3. Fetches user profile
  4. Creates or finds user by email
  5. Creates account record
  6. Creates session
  7. Redirects to application
The OAuth flow uses PKCE (Proof Key for Code Exchange) to prevent authorization code interception attacks. The code verifier and challenge are generated in oauth.ts:1-25.

Account Linking

When a user signs in with OAuth, the system:
  1. Looks up user by email (from OAuth profile)
  2. If user exists, links the OAuth account
  3. If user doesn’t exist, creates new user
// From oauth-callback.ts:66-103
let user = await ctx.adapter.findUserByEmail(profile.email)

if (!user) {
  // Create new user
  user = await ctx.adapter.createUser({
    email: profile.email,
    phone: null,
    name: profile.name ?? null,
    emailVerified: profile.emailVerified ?? false,
    phoneVerified: false,
    image: profile.image ?? null
  })
}

// Link OAuth account
const existingAccount = await ctx.adapter.findAccount(
  oauthProvider.id,
  profile.id
)

if (!existingAccount) {
  await ctx.adapter.createAccount({
    userId: user.id,
    providerId: oauthProvider.id,
    accountId: profile.id,
    accessToken: tokens.accessToken,
    refreshToken: tokens.refreshToken,
    accessTokenExpiresAt: tokens.expiresIn
      ? new Date(Date.now() + tokens.expiresIn * 1000)
      : undefined
  })
}

Client Implementation

Example sign-in buttons:
import { useState } from 'react'

export function OAuthButtons() {
  const [loading, setLoading] = useState<string | null>(null)

  const signInWith = (provider: string) => {
    setLoading(provider)
    window.location.href = `/api/auth/${provider}`
  }

  return (
    <div className="space-y-3">
      <button
        onClick={() => signInWith('google')}
        disabled={loading !== null}
        className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50"
      >
        <GoogleIcon />
        {loading === 'google' ? 'Connecting...' : 'Continue with Google'}
      </button>

      <button
        onClick={() => signInWith('github')}
        disabled={loading !== null}
        className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50"
      >
        <GitHubIcon />
        {loading === 'github' ? 'Connecting...' : 'Continue with GitHub'}
      </button>
    </div>
  )
}

Custom Redirect

Redirect users to a specific page after OAuth:
function signInWithGoogle(redirectTo?: string) {
  const url = new URL('/api/auth/google', window.location.origin)
  if (redirectTo) {
    url.searchParams.set('redirectTo', redirectTo)
  }
  window.location.href = url.toString()
}

// Usage
signInWithGoogle('/dashboard')
The redirect parameter is preserved through the OAuth flow and used in the callback (see oauth-callback.ts:121).

Token Refresh

If your provider supports refresh tokens, you can implement token refresh:
export async function refreshOAuthToken(
  providerId: string,
  refreshToken: string
) {
  const provider = providers.find(p => p.id === providerId)
  if (!provider) throw new Error('Provider not found')

  const response = await fetch(provider.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: provider.clientId,
      client_secret: provider.clientSecret,
      refresh_token: refreshToken,
      grant_type: 'refresh_token'
    })
  })

  const data = await response.json()

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token || refreshToken,
    expiresIn: data.expires_in
  }
}

Security Considerations

1
State Validation
2
The state parameter prevents CSRF attacks. It’s automatically:
3
  • Generated and stored in a cookie before redirect
  • Validated in the callback handler
  • Expires after 10 minutes (see oauth-start.ts:22-27)
  • 4
    PKCE Implementation
    5
    PKCE adds an extra layer of security:
    6
    // Code verifier: Random string
    const codeVerifier = await generateCodeVerifier()
    
    // Code challenge: SHA-256 hash of verifier
    const codeChallenge = await generateCodeChallenge(codeVerifier)
    
    // Send challenge in authorization URL
    // Send verifier when exchanging code
    
    7
    This prevents authorization code interception attacks.
    8
    Email Verification
    9
    Some providers indicate if the email is verified:
    10
    // Google returns verified_email
    emailVerified: profile.verified_email
    
    // GitHub email verification status
    emailVerified: primaryEmail?.verified || false
    
    11
    The emailVerified field is stored with the user.
    Production Checklist:
    • Use HTTPS for all OAuth redirects
    • Set secure cookies (secure: true)
    • Add your production domain to provider’s allowed redirect URIs
    • Store client secrets securely (use environment variables)
    • Validate redirect URLs to prevent open redirects

    Error Handling

    Handle OAuth errors gracefully:
    // If user denies permission
    if (error) {
      return Response.json({ error: `OAuth denied: ${error}` }, { status: 400 })
    }
    
    // If state validation fails
    if (!savedState || savedState !== state) {
      return Response.json(
        { error: 'Invalid state - possible CSRF attack' },
        { status: 400 }
      )
    }
    
    // If provider doesn't return email
    if (!profile.email) {
      return Response.json(
        { error: 'Provider did not return an email' },
        { status: 400 }
      )
    }
    

    Plugin Hooks

    Use hooks to customize OAuth behavior:
    const oauthPlugin = {
      id: 'oauth-hooks',
      hooks: {
        beforeSignUp: async (data) => {
          // Check if OAuth sign-ups are allowed
          if (!data.email?.endsWith('@company.com')) {
            throw new Error('Only company emails allowed')
          }
        },
        afterSignIn: async (user, session) => {
          // Log OAuth sign-in
          await analytics.track('oauth_signin', {
            userId: user.id,
            provider: session.provider // You'd need to pass this
          })
        }
      }
    }
    
    Best Practices:
    • Request minimal scopes needed for your application
    • Display clear consent screens to users
    • Handle provider outages gracefully
    • Implement account unlinking functionality
    • Test OAuth flow in incognito/private browsing

    Next Steps

    Build docs developers (and LLMs) love