OAuth Integration Best Practices

Comprehensive guide to implementing OAuth authentication securely and efficiently.


Overview

OAuth 2.0 is the industry-standard protocol for authorization. This guide covers best practices for integrating OAuth providers with your application using the OpenDev platform.


Architecture Patterns

┌─────────┐     ┌─────────┐     ┌──────────┐     ┌──────────┐
│  User   │────▶│  App    │────▶│ OpenDev  │────▶│ Provider │
│         │◀────│ Client  │◀────│ Backend  │◀────│ (Google) │
└─────────┘     └─────────┘     └──────────┘     └──────────┘
     1. Click Login    2. Redirect    3. OAuth Flow    4. Token

Mobile App Flow

For mobile applications, use the Authorization Code Flow with PKCE:

// 1. Generate code verifier and challenge
const codeVerifier = generateRandomString(64);
const codeChallenge = base64UrlEncode(sha256(codeVerifier));

// 2. Start OAuth flow
const authUrl = `${provider}/authorize?
  client_id=${clientId}&
  redirect_uri=${redirectUri}&
  code_challenge=${codeChallenge}&
  code_challenge_method=S256&
  response_type=code`;

// 3. Exchange code for tokens (include code_verifier)
const tokens = await exchangeCode(code, codeVerifier);

Web App Flow

For web applications, use the Server-side Flow:

// Server-side: Exchange authorization code for tokens
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  
  // Verify state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }
  
  // Exchange code for tokens
  const tokens = await oauth.exchangeCode(code);
  
  // Store tokens securely
  req.session.accessToken = tokens.access_token;
});

Provider-Specific Guidelines

Google OAuth

Configuration Requirements:

  • Enable Google+ API and People API
  • Configure OAuth consent screen
  • Set authorized redirect URIs

Scopes:

const scopes = [
  'openid',
  'email',
  'profile'
];

Token Refresh:

// Google tokens refresh automatically
// Use refresh_token to get new access_token
const newTokens = await oauth2Client.refreshAccessToken(refreshToken);

Apple Sign In

Key Considerations:

  • Requires App ID with Sign In with Apple capability
  • Service ID for web authentication
  • Private key for token generation

Web vs Native:

Feature Web Native iOS
Auth Method Popup/Redirect Native UI
Private Key Required Not Required
User Data Limited on refresh Full access

Handling User Data:

// Apple only provides name on first auth
// Store it immediately
if (user.name) {
  await saveUserProfile(user.id, {
    firstName: user.name.firstName,
    lastName: user.name.lastName
  });
}

WeChat OAuth

Environment Handling:

// Different apps for different platforms
const wechatConfig = {
  mobile: {
    appId: 'wx_mobile_app_id',
    appSecret: 'mobile_secret'
  },
  web: {
    appId: 'wx_web_app_id', 
    appSecret: 'web_secret'
  },
  miniProgram: {
    appId: 'wx_mini_program_id',
    appSecret: 'mini_program_secret'
  }
};

UnionID vs OpenID:

  • OpenID: Unique per application
  • UnionID: Same across all apps under one account
  • Use UnionID for cross-app user identification


Security Best Practices

Token Storage

Client-Side (Web):

// DON'T: Store in localStorage (XSS vulnerable)
localStorage.setItem('token', accessToken);

// DO: Use httpOnly cookies
res.cookie('session', sessionToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',
  maxAge: 3600000
});

Mobile:

// iOS: Use Keychain
let keychain = Keychain(service: "com.yourapp")
keychain["accessToken"] = accessToken

// Android: Use EncryptedSharedPreferences
val prefs = EncryptedSharedPreferences.create(...)
prefs.edit().putString("accessToken", token).apply()

State Parameter

Always use cryptographically secure state:

const crypto = require('crypto');

function generateState() {
  return crypto.randomBytes(32).toString('hex');
}

// Store in session before redirect
req.session.oauthState = generateState();

// Verify on callback
if (req.query.state !== req.session.oauthState) {
  throw new Error('State mismatch - possible CSRF attack');
}

Nonce for ID Tokens

For OpenID Connect providers:

const nonce = crypto.randomBytes(16).toString('hex');
req.session.nonce = nonce;

const authUrl = `${provider}/authorize?nonce=${nonce}&...`;

// Verify nonce in ID token
const decoded = jwt.verify(idToken, publicKey);
if (decoded.nonce !== req.session.nonce) {
  throw new Error('Nonce mismatch');
}

Error Handling

Common OAuth Errors

app.get('/auth/callback', async (req, res) => {
  const { error, error_description } = req.query;
  
  if (error) {
    switch (error) {
      case 'access_denied':
        // User cancelled authentication
        return res.redirect('/login?error=cancelled');
        
      case 'invalid_scope':
        // Requested scope not allowed
        return res.redirect('/login?error=scope');
        
      case 'server_error':
        // Provider server issue
        return res.redirect('/login?error=server');
        
      default:
        logger.error('OAuth error', { error, error_description });
        return res.redirect('/login?error=unknown');
    }
  }
  
  // Continue with token exchange...
});

Retry Logic

async function exchangeCodeWithRetry(code, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await exchangeCode(code);
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      
      // Exponential backoff
      const delay = Math.pow(2, i) * 1000;
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

Performance Optimization

Token Caching

const tokenCache = new Map();

async function getValidToken(userId) {
  const cached = tokenCache.get(userId);
  
  if (cached && cached.expiresAt > Date.now()) {
    return cached.accessToken;
  }
  
  // Refresh token
  const newTokens = await refreshToken(userId);
  
  tokenCache.set(userId, {
    accessToken: newTokens.access_token,
    expiresAt: Date.now() + (newTokens.expires_in * 1000)
  });
  
  return newTokens.access_token;
}

Parallel Provider Initialization

// Initialize all OAuth clients in parallel
const [googleClient, facebookClient, appleClient] = await Promise.all([
  initializeGoogle(),
  initializeFacebook(),
  initializeApple()
]);

Testing

Unit Testing OAuth

describe('OAuth Flow', () => {
  it('should generate valid state', () => {
    const state = generateState();
    expect(state).toHaveLength(64);
    expect(state).toMatch(/^[a-f0-9]+$/);
  });
  
  it('should verify callback state', async () => {
    req.session.oauthState = 'test-state';
    req.query.state = 'different-state';
    
    await expect(handleCallback(req, res))
      .rejects.toThrow('State mismatch');
  });
});

Integration Testing

describe('OAuth Integration', () => {
  it('should complete Google OAuth flow', async () => {
    // Mock Google responses
    nock('https://oauth2.googleapis.com')
      .post('/token')
      .reply(200, {
        access_token: 'mock-token',
        refresh_token: 'mock-refresh',
        expires_in: 3600
      });
    
    const result = await completeOAuthFlow('google', mockCode);
    expect(result.accessToken).toBe('mock-token');
  });
});

Monitoring & Logging

Key Metrics

// Track OAuth metrics
const metrics = {
  oauthAttempts: new Counter('oauth_attempts_total'),
  oauthSuccess: new Counter('oauth_success_total'),
  oauthFailure: new Counter('oauth_failure_total'),
  oauthDuration: new Histogram('oauth_duration_seconds')
};

async function handleOAuth(provider) {
  metrics.oauthAttempts.inc({ provider });
  const timer = metrics.oauthDuration.startTimer({ provider });
  
  try {
    const result = await processOAuth(provider);
    metrics.oauthSuccess.inc({ provider });
    return result;
  } catch (error) {
    metrics.oauthFailure.inc({ provider, error: error.code });
    throw error;
  } finally {
    timer();
  }
}

Audit Logging

function logOAuthEvent(event) {
  logger.info('OAuth event', {
    type: event.type,
    provider: event.provider,
    userId: event.userId,
    ip: event.ipAddress,
    userAgent: event.userAgent,
    timestamp: new Date().toISOString()
  });
}

Last updated: January 2026