Step 4: Handle Callback

After users authenticate with Oten IDP, they're redirected back to your application with an authorization code. This step shows you how to handle the callback, validate parameters, and exchange the code for tokens.

๐Ÿ“– Need context? Check the Integration Flow Overview to see how this step fits into the complete process.

๐ŸŽฏ What You'll Learn

In this step, you will:

  • Handle the OAuth callback request

  • Validate state parameter and other security checks

  • Exchange authorization code for tokens

  • Handle errors and edge cases

  • Implement proper error handling and logging

๐Ÿ”„ Callback Flow Overview

Callback Flow Overview

๐Ÿ” Callback Request Structure

Successful Callback

When authentication succeeds, Oten redirects to your callback URL with:

https://yourapp.com/callback?code=AUTH_CODE&state=STATE_VALUE

Parameters:

  • code: Authorization code (10-minute expiry)

  • state: The state value you sent (for CSRF protection)

Error Callback

When authentication fails, the callback includes error information:

https://yourapp.com/callback?error=access_denied&error_description=User+denied+access&state=STATE_VALUE

Error Parameters:

  • error: Error code (e.g., access_denied, invalid_request)

  • error_description: Human-readable error description

  • state: The state value (still present for validation)

๐Ÿ” Security Validation

State Parameter Validation

function validateState(receivedState, req) {
  const storedState = req.session.oauthState;
  
  // Clear stored state (one-time use)
  delete req.session.oauthState;
  
  if (!receivedState) {
    throw new Error('Missing state parameter');
  }
  
  if (!storedState) {
    throw new Error('No stored state found - possible session timeout');
  }
  
  if (receivedState !== storedState) {
    throw new Error('Invalid state parameter - possible CSRF attack');
  }
  
  return true;
}

Authorization Code Validation

function validateAuthorizationCode(code) {
  if (!code) {
    throw new Error('Missing authorization code');
  }
  
  if (typeof code !== 'string') {
    throw new Error('Invalid authorization code format');
  }
  
  // Basic length validation (codes are typically 20-512 characters)
  if (code.length < 10 || code.length > 512) {
    throw new Error('Authorization code length invalid');
  }
  
  // Check for valid characters (base64url)
  if (!/^[A-Za-z0-9_-]+$/.test(code)) {
    throw new Error('Authorization code contains invalid characters');
  }
  
  return true;
}

๐Ÿ”„ Token Exchange

Basic Token Exchange

async function exchangeCodeForTokens(code, codeVerifier = null) {
  const tokenRequest = {
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: process.env.OTEN_REDIRECT_URI,
    client_id: process.env.OTEN_CLIENT_ID
  };
  
  // Add client secret for confidential clients
  if (process.env.OTEN_CLIENT_SECRET) {
    tokenRequest.client_secret = process.env.OTEN_CLIENT_SECRET;
  }
  
  // Add PKCE code verifier for public clients
  if (codeVerifier) {
    tokenRequest.code_verifier = codeVerifier;
  }
  
  try {
    const response = await fetch('https://account.oten.com/v1/oauth/token', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: new URLSearchParams(tokenRequest)
    });
    
    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(`Token exchange failed: ${errorData.error_description || errorData.error}`);
    }
    
    const tokens = await response.json();
    return tokens;
    
  } catch (error) {
    console.error('Token exchange error:', error);
    throw error;
  }
}

Token Response Structure

// Successful token response
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "def50200...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "scope": "openid profile email"
}

Token Validation

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// Create JWKS client for token validation
const client = jwksClient({
  jwksUri: 'https://account.oten.com/.well-known/jwks.json',
  cache: true,
  cacheMaxAge: 86400000, // 24 hours
  rateLimit: true,
  jwksRequestsPerMinute: 5
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) {
      callback(err);
      return;
    }
    
    const signingKey = key.publicKey || key.rsaPublicKey;
    callback(null, signingKey);
  });
}

async function validateIDToken(idToken) {
  return new Promise((resolve, reject) => {
    jwt.verify(idToken, getKey, {
      issuer: 'https://account.oten.com',
      audience: process.env.OTEN_CLIENT_ID,
      algorithms: ['RS256']
    }, (err, decoded) => {
      if (err) {
        reject(new Error(`Invalid ID token: ${err.message}`));
        return;
      }
      
      // Additional validations
      const now = Math.floor(Date.now() / 1000);
      
      if (decoded.exp <= now) {
        reject(new Error('ID token has expired'));
        return;
      }
      
      if (decoded.iat > now + 300) { // Allow 5 minutes clock skew
        reject(new Error('ID token issued in the future'));
        return;
      }
      
      resolve(decoded);
    });
  });
}

๐ŸŒ Implementation Examples

Express.js Callback Handler

app.get('/callback', async (req, res) => {
  try {
    const { code, state, error, error_description } = req.query;
    
    // Handle OAuth errors
    if (error) {
      console.error('OAuth error:', error, error_description);
      return res.redirect('/login?error=' + encodeURIComponent(error_description || error));
    }
    
    // Validate state parameter
    validateState(state, req);
    
    // Validate authorization code
    validateAuthorizationCode(code);
    
    // Get code verifier from session (if using PKCE)
    const codeVerifier = req.session.codeVerifier;
    delete req.session.codeVerifier; // Clear after use
    
    // Exchange code for tokens
    const tokens = await exchangeCodeForTokens(code, codeVerifier);
    
    // Validate ID token
    const idTokenClaims = await validateIDToken(tokens.id_token);
    
    // Store tokens and user information
    await storeUserSession(req, tokens, idTokenClaims);
    
    // Redirect to application
    res.redirect('/dashboard');
    
  } catch (error) {
    console.error('Callback error:', error);
    res.redirect('/login?error=' + encodeURIComponent('Authentication failed'));
  }
});

async function storeUserSession(req, tokens, userClaims) {
  // Store user information in session
  req.session.user = {
    id: userClaims.sub,
    email: userClaims.email,
    name: userClaims.name,
    workspace: userClaims.workspace
  };
  
  // Store tokens securely (encrypted)
  req.session.tokens = {
    accessToken: encrypt(tokens.access_token),
    refreshToken: encrypt(tokens.refresh_token),
    idToken: encrypt(tokens.id_token),
    expiresAt: new Date(Date.now() + tokens.expires_in * 1000)
  };
  
  // Log successful authentication
  console.log('User authenticated:', userClaims.sub, userClaims.email);
}

React SPA Callback Handler

// CallbackPage.jsx
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

function CallbackPage() {
  const [searchParams] = useSearchParams();
  const navigate = useNavigate();
  const [status, setStatus] = useState('processing');
  const [error, setError] = useState(null);
  
  useEffect(() => {
    handleCallback();
  }, []);
  
  async function handleCallback() {
    try {
      const code = searchParams.get('code');
      const state = searchParams.get('state');
      const error = searchParams.get('error');
      const errorDescription = searchParams.get('error_description');
      
      if (error) {
        throw new Error(errorDescription || error);
      }
      
      if (!code) {
        throw new Error('No authorization code received');
      }
      
      // Validate state
      const storedState = sessionStorage.getItem('oauth_state');
      sessionStorage.removeItem('oauth_state');
      
      if (state !== storedState) {
        throw new Error('Invalid state parameter');
      }
      
      // Exchange code for tokens
      const codeVerifier = sessionStorage.getItem('code_verifier');
      sessionStorage.removeItem('code_verifier');
      
      const tokens = await exchangeCodeForTokens(code, codeVerifier);
      
      // Store tokens
      localStorage.setItem('access_token', tokens.access_token);
      localStorage.setItem('refresh_token', tokens.refresh_token);
      localStorage.setItem('id_token', tokens.id_token);
      
      // Parse user info from ID token
      const userInfo = parseJWT(tokens.id_token);
      localStorage.setItem('user_info', JSON.stringify(userInfo));
      
      setStatus('success');
      
      // Redirect to app
      setTimeout(() => {
        navigate('/dashboard');
      }, 1000);
      
    } catch (error) {
      console.error('Callback error:', error);
      setError(error.message);
      setStatus('error');
    }
  }
  
  if (status === 'processing') {
    return <div>Processing login...</div>;
  }
  
  if (status === 'error') {
    return (
      <div>
        <h2>Login Failed</h2>
        <p>{error}</p>
        <button onClick={() => navigate('/login')}>Try Again</button>
      </div>
    );
  }
  
  return <div>Login successful! Redirecting...</div>;
}

function parseJWT(token) {
  const base64Url = token.split('.')[1];
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
    return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
  }).join(''));
  
  return JSON.parse(jsonPayload);
}

Python Flask Callback Handler

@app.route('/callback')
def callback():
    try:
        # Check for OAuth errors
        error = request.args.get('error')
        if error:
            error_description = request.args.get('error_description', error)
            flash(f'Authentication failed: {error_description}')
            return redirect(url_for('login'))
        
        # Get authorization code
        code = request.args.get('code')
        if not code:
            flash('No authorization code received')
            return redirect(url_for('login'))
        
        # Validate state parameter
        received_state = request.args.get('state')
        stored_state = session.pop('oauth_state', None)
        
        if not received_state or received_state != stored_state:
            flash('Invalid state parameter')
            return redirect(url_for('login'))
        
        # Exchange code for tokens
        code_verifier = session.pop('code_verifier', None)
        token = oten.authorize_access_token(
            code_verifier=code_verifier
        )
        
        # Parse user info from ID token
        user_info = oten.parse_id_token(token)
        
        # Store user session
        session['user'] = {
            'id': user_info['sub'],
            'email': user_info['email'],
            'name': user_info['name']
        }
        
        # Store tokens (encrypted)
        session['tokens'] = encrypt_tokens(token)
        
        flash('Login successful!')
        return redirect(url_for('dashboard'))
        
    except Exception as e:
        app.logger.error(f'Callback error: {str(e)}')
        flash('Authentication failed')
        return redirect(url_for('login'))

๐Ÿšจ Error Handling

๐Ÿ“– Complete Error Reference: See Error Codes Reference for all error codes and handling guidelines.

Authorization Callback Error Scenarios

Authorization errors are returned as URL parameters in the callback. Handle these according to OAuth 2.0 standards:

function handleAuthorizationCallbackError(error, errorDescription, state, req, res) {
  // Validate state parameter first
  const storedState = req.session.oauthState;
  if (state !== storedState) {
    console.error('State parameter mismatch - possible CSRF attack');
    return res.redirect('/login?error=security_error');
  }

  const errorMap = {
    // Standard OAuth 2.0 errors
    'access_denied': {
      message: 'You denied access to the application',
      action: 'Please try again and grant access to continue',
      redirect: '/login',
      severity: 'info'
    },
    'invalid_request': {
      message: 'Invalid authentication request',
      action: 'Please contact support if this persists',
      redirect: '/login',
      severity: 'error'
    },
    'invalid_client': {
      message: 'Application configuration error',
      action: 'Please contact support',
      redirect: '/error',
      severity: 'error'
    },
    'unauthorized_client': {
      message: 'Application not authorized',
      action: 'Please contact your administrator',
      redirect: '/error',
      severity: 'error'
    },
    'unsupported_response_type': {
      message: 'Authentication method not supported',
      action: 'Please contact support',
      redirect: '/error',
      severity: 'error'
    },
    'invalid_scope': {
      message: 'Requested permissions not available',
      action: 'Please contact support',
      redirect: '/error',
      severity: 'error'
    },
    'server_error': {
      message: 'Authentication service temporarily unavailable',
      action: 'Please try again in a few minutes',
      redirect: '/login',
      severity: 'warning'
    },
    'temporarily_unavailable': {
      message: 'Authentication service is busy',
      action: 'Please wait a moment and try again',
      redirect: '/login',
      severity: 'warning'
    },

    // JAR-specific errors
    'invalid_request_object': {
      message: 'Authentication request format error',
      action: 'Please contact support',
      redirect: '/error',
      severity: 'error'
    },
    'request_not_supported': {
      message: 'Authentication method not supported',
      action: 'Please contact support',
      redirect: '/error',
      severity: 'error'
    },

    // Oten specific scenarios (using standard error codes)
    'invalid_request': {
      message: 'Authentication request error',
      action: 'Please try logging in again or contact support',
      redirect: '/login',
      severity: 'warning'
    },
    'workspace_required': {
      message: 'Workspace selection required',
      action: 'Please select a workspace and try again',
      redirect: '/workspace-selection',
      severity: 'info'
    },
    'mfa_required': {
      message: 'Multi-factor authentication required',
      action: 'Please complete additional authentication steps',
      redirect: '/mfa',
      severity: 'info'
    }
  };

  const errorInfo = errorMap[error] || {
    message: 'Authentication failed',
    action: 'Please try again',
    redirect: '/login',
    severity: 'error'
  };

  // Log error for debugging
  console.error('OAuth authorization callback error:', {
    error,
    errorDescription,
    state,
    severity: errorInfo.severity,
    userAgent: req.headers['user-agent'],
    ip: req.ip,
    timestamp: new Date().toISOString()
  });

  // Clear OAuth session data
  delete req.session.oauthState;
  delete req.session.codeVerifier;

  // Redirect with user-friendly error
  const errorQuery = new URLSearchParams({
    error: errorInfo.message,
    action: errorInfo.action,
    severity: errorInfo.severity
  });

  res.redirect(`${errorInfo.redirect}?${errorQuery}`);
}

Token Exchange Error Handling

Token exchange errors are returned as JSON responses. Handle these according to OAuth 2.0 token endpoint specifications:

async function handleTokenExchangeError(error, response) {
  // Parse error response
  let errorData;
  try {
    errorData = await response.json();
  } catch (parseError) {
    errorData = {
      error: 'server_error',
      error_description: 'Unable to parse error response'
    };
  }

  const { error: errorCode, error_description: errorDescription } = errorData;

  // Map token errors to user actions
  const tokenErrorMap = {
    // Standard OAuth 2.0 token errors
    'invalid_request': {
      message: 'Invalid token request',
      action: 'restart_auth',
      retryable: false,
      severity: 'error'
    },
    'invalid_client': {
      message: 'Client authentication failed',
      action: 'contact_support',
      retryable: false,
      severity: 'error'
    },
    'invalid_grant': {
      message: 'Authorization code invalid or expired',
      action: 'restart_auth',
      retryable: false,
      severity: 'warning'
    },
    'unauthorized_client': {
      message: 'Client not authorized for this grant type',
      action: 'contact_support',
      retryable: false,
      severity: 'error'
    },
    'unsupported_grant_type': {
      message: 'Grant type not supported',
      action: 'contact_support',
      retryable: false,
      severity: 'error'
    },
    'invalid_scope': {
      message: 'Requested scope invalid',
      action: 'restart_auth',
      retryable: false,
      severity: 'warning'
    },
    'server_error': {
      message: 'Token service temporarily unavailable',
      action: 'retry',
      retryable: true,
      severity: 'warning'
    }
  };

  const errorInfo = tokenErrorMap[errorCode] || {
    message: 'Token exchange failed',
    action: 'restart_auth',
    retryable: false,
    severity: 'error'
  };

  // Log detailed error information
  console.error('Token exchange error:', {
    errorCode,
    errorDescription,
    httpStatus: response.status,
    action: errorInfo.action,
    retryable: errorInfo.retryable,
    severity: errorInfo.severity,
    timestamp: new Date().toISOString()
  });

  return {
    ...errorInfo,
    errorCode,
    errorDescription,
    httpStatus: response.status
  };
}

async function exchangeCodeForTokensWithRetry(code, codeVerifier, maxRetries = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch('/oauth/token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: new URLSearchParams({
          grant_type: 'authorization_code',
          code: code,
          redirect_uri: process.env.REDIRECT_URI,
          client_id: process.env.CLIENT_ID,
          client_secret: process.env.CLIENT_SECRET,
          code_verifier: codeVerifier
        })
      });

      if (response.ok) {
        return await response.json();
      }

      // Handle token exchange error
      const errorInfo = await handleTokenExchangeError(null, response);

      // Don't retry for non-retryable errors
      if (!errorInfo.retryable) {
        throw new Error(`Token exchange failed: ${errorInfo.message}`);
      }

      lastError = new Error(`Token exchange failed (attempt ${attempt}): ${errorInfo.message}`);

      // Exponential backoff for retryable errors
      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
        await new Promise(resolve => setTimeout(resolve, delay));
      }

    } catch (error) {
      lastError = error;
      
      // Don't retry on client errors (4xx)
      if (error.message.includes('400') || error.message.includes('401')) {
        throw error;
      }
      
      // Retry on server errors (5xx) with exponential backoff
      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
        await new Promise(resolve => setTimeout(resolve, delay));
        console.log(`Token exchange attempt ${attempt} failed, retrying in ${delay}ms...`);
      }
    }
  }
  
  throw lastError;
}

Logging and Monitoring

Authentication Event Logging

function logAuthenticationEvent(event, details) {
  const logEntry = {
    timestamp: new Date().toISOString(),
    event: event,
    details: details,
    source: 'oauth-callback'
  };
  
  // Send to logging service
  console.log('Auth event:', JSON.stringify(logEntry));
  
  // Send metrics to monitoring service
  if (event === 'callback_success') {
    metrics.increment('auth.callback.success');
  } else if (event === 'callback_error') {
    metrics.increment('auth.callback.error', { error_type: details.error });
  }
}

// Usage in callback handler
logAuthenticationEvent('callback_success', {
  userId: userClaims.sub,
  email: userClaims.email,
  workspace: userClaims.workspace
});

Callback Handler Checklist

Ensure your callback handler:


Progress: Step 4 of 5 complete โœ…

Last updated