Skip to content

Vanilla JavaScript OAuth Integration

Complete example of integrating Flowsta authentication without npm or build tools - just plain HTML and JavaScript.

Overview

This guide shows how to implement the full OAuth 2.0 with PKCE flow using only vanilla JavaScript. Perfect for:

  • Static HTML sites
  • Sites without build tools
  • Learning how OAuth works under the hood
  • Quick prototypes

Complete Example

1. Login Page (index.html)

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Sign in with Flowsta - Vanilla JS Example</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      max-width: 600px;
      margin: 50px auto;
      padding: 20px;
      text-align: center;
    }
    .login-btn {
      cursor: pointer;
      border: none;
      background: transparent;
      padding: 0;
      transition: opacity 0.2s;
    }
    .login-btn:hover {
      opacity: 0.8;
    }
  </style>
</head>
<body>
  <h1>Welcome to My App</h1>
  <p>Sign in with your Flowsta account to continue</p>
  
  <button id="login-btn" class="login-btn">
    <img src="https://docs.flowsta.com/buttons/svg/flowsta_signin_web_dark_pill.svg" 
         alt="Sign in with Flowsta" 
         width="175" 
         height="40">
  </button>

  <script>
    // Configuration
    const CONFIG = {
      clientId: 'YOUR_CLIENT_ID', // Get this from https://dev.flowsta.com
      redirectUri: window.location.origin + '/callback.html',
      authDomain: 'https://auth-api.flowsta.com',
      scopes: ['openid', 'email', 'display_name', 'username']
    };

    // PKCE Helper Functions
    function generateRandomString(length) {
      const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
      const randomValues = new Uint8Array(length);
      crypto.getRandomValues(randomValues);
      return Array.from(randomValues)
        .map(v => charset[v % charset.length])
        .join('');
    }

    async function sha256(plain) {
      const encoder = new TextEncoder();
      const data = encoder.encode(plain);
      return crypto.subtle.digest('SHA-256', data);
    }

    function base64urlencode(buffer) {
      const bytes = new Uint8Array(buffer);
      let str = '';
      bytes.forEach(byte => str += String.fromCharCode(byte));
      return btoa(str)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
    }

    async function generatePKCEChallenge(verifier) {
      const hashed = await sha256(verifier);
      return base64urlencode(hashed);
    }

    // Login Handler
    document.getElementById('login-btn').addEventListener('click', async () => {
      try {
        // Generate PKCE values
        const codeVerifier = generateRandomString(128);
        const codeChallenge = await generatePKCEChallenge(codeVerifier);
        const state = generateRandomString(32);

        // Store for callback
        sessionStorage.setItem('pkce_code_verifier', codeVerifier);
        sessionStorage.setItem('oauth_state', state);

        // Build authorization URL
        const params = new URLSearchParams({
          client_id: CONFIG.clientId,
          redirect_uri: CONFIG.redirectUri,
          response_type: 'code',
          scope: CONFIG.scopes.join(' '),
          state: state,
          code_challenge: codeChallenge,
          code_challenge_method: 'S256'
        });

        const authUrl = `${CONFIG.authDomain}/oauth/authorize?${params}`;
        
        // Redirect to Flowsta
        window.location.href = authUrl;
      } catch (error) {
        console.error('Login error:', error);
        alert('Failed to initiate login');
      }
    });
  </script>
</body>
</html>

2. Callback Page (callback.html)

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Authenticating...</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      max-width: 600px;
      margin: 50px auto;
      padding: 20px;
      text-align: center;
    }
    .spinner {
      border: 4px solid #f3f3f3;
      border-top: 4px solid #3498db;
      border-radius: 50%;
      width: 50px;
      height: 50px;
      animation: spin 1s linear infinite;
      margin: 20px auto;
    }
    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
    .error {
      color: #e74c3c;
      padding: 20px;
      background: #fadbd8;
      border-radius: 8px;
      margin-top: 20px;
    }
  </style>
</head>
<body>
  <h2>Completing authentication...</h2>
  <div class="spinner"></div>
  <div id="status">Please wait</div>
  <div id="error" class="error" style="display: none;"></div>

  <script>
    // Configuration (must match login page)
    const CONFIG = {
      clientId: 'YOUR_CLIENT_ID',
      redirectUri: window.location.origin + '/callback.html',
      authDomain: 'https://auth-api.flowsta.com'
    };

    async function handleCallback() {
      try {
        // Parse URL parameters
        const params = new URLSearchParams(window.location.search);
        const code = params.get('code');
        const state = params.get('state');
        const error = params.get('error');

        // Check for errors
        if (error) {
          throw new Error(params.get('error_description') || error);
        }

        if (!code) {
          throw new Error('No authorization code received');
        }

        // Verify state (CSRF protection)
        const savedState = sessionStorage.getItem('oauth_state');
        if (state !== savedState) {
          throw new Error('State mismatch - possible CSRF attack');
        }

        // Get PKCE verifier
        const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
        if (!codeVerifier) {
          throw new Error('No PKCE verifier found');
        }

        // Update status
        document.getElementById('status').textContent = 'Exchanging authorization code...';

        // Exchange code for tokens
        const tokenResponse = await fetch(`${CONFIG.authDomain}/oauth/token`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            grant_type: 'authorization_code',
            code: code,
            redirect_uri: CONFIG.redirectUri,
            client_id: CONFIG.clientId,
            code_verifier: codeVerifier
          })
        });

        if (!tokenResponse.ok) {
          const errorData = await tokenResponse.json();
          throw new Error(errorData.error_description || 'Token exchange failed');
        }

        const tokens = await tokenResponse.json();
        
        // Update status
        document.getElementById('status').textContent = 'Fetching user profile...';

        // Get user info
        const userResponse = await fetch(`${CONFIG.authDomain}/oauth/userinfo`, {
          headers: {
            'Authorization': `Bearer ${tokens.access_token}`
          }
        });

        if (!userResponse.ok) {
          throw new Error('Failed to fetch user info');
        }

        const user = await userResponse.json();

        // Store tokens and user (in a real app, send to your backend)
        sessionStorage.setItem('access_token', tokens.access_token);
        sessionStorage.setItem('user', JSON.stringify(user));

        // Clean up
        sessionStorage.removeItem('pkce_code_verifier');
        sessionStorage.removeItem('oauth_state');

        // Update status
        document.getElementById('status').textContent = 'Success! Redirecting...';

        // Redirect to dashboard
        setTimeout(() => {
          window.location.href = '/dashboard.html';
        }, 1000);

      } catch (error) {
        console.error('Authentication error:', error);
        document.getElementById('status').style.display = 'none';
        document.querySelector('.spinner').style.display = 'none';
        const errorDiv = document.getElementById('error');
        errorDiv.textContent = `Authentication failed: ${error.message}`;
        errorDiv.style.display = 'block';

        // Redirect back to login after 3 seconds
        setTimeout(() => {
          window.location.href = '/';
        }, 3000);
      }
    }

    // Run on page load
    handleCallback();
  </script>
</body>
</html>

3. Dashboard Page (dashboard.html)

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Dashboard</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      max-width: 800px;
      margin: 0 auto;
      padding: 20px;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 20px;
      background: #f5f5f5;
      border-radius: 8px;
      margin-bottom: 30px;
    }
    .user-info {
      display: flex;
      align-items: center;
      gap: 15px;
    }
    .avatar {
      width: 50px;
      height: 50px;
      border-radius: 50%;
      background: #3498db;
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 20px;
      font-weight: bold;
    }
    .logout-btn {
      padding: 10px 20px;
      background: #e74c3c;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    .logout-btn:hover {
      background: #c0392b;
    }
    .profile-data {
      background: white;
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 20px;
    }
    .data-row {
      display: flex;
      padding: 10px 0;
      border-bottom: 1px solid #eee;
    }
    .data-row:last-child {
      border-bottom: none;
    }
    .data-label {
      font-weight: bold;
      width: 150px;
    }
  </style>
</head>
<body>
  <div class="header">
    <div class="user-info">
      <div class="avatar" id="avatar"></div>
      <div>
        <h2 id="display-name" style="margin: 0;">Loading...</h2>
        <p id="username" style="margin: 5px 0 0 0; color: #666;"></p>
      </div>
    </div>
    <button class="logout-btn" onclick="logout()">Logout</button>
  </div>

  <div class="profile-data">
    <h3>Your Profile</h3>
    <div id="profile-details"></div>
  </div>

  <script>
    function checkAuth() {
      const user = sessionStorage.getItem('user');
      const token = sessionStorage.getItem('access_token');

      if (!user || !token) {
        // Not authenticated, redirect to login
        window.location.href = '/';
        return null;
      }

      return JSON.parse(user);
    }

    function logout() {
      sessionStorage.removeItem('access_token');
      sessionStorage.removeItem('user');
      window.location.href = '/';
    }

    function displayUser() {
      const user = checkAuth();
      if (!user) return;

      // Display avatar (first letter of display name)
      const avatarLetter = (user.display_name || user.email || 'U')[0].toUpperCase();
      document.getElementById('avatar').textContent = avatarLetter;

      // Display name and username
      document.getElementById('display-name').textContent = user.display_name || 'User';
      document.getElementById('username').textContent = user.username ? `@${user.username}` : '';

      // Display all profile data
      const detailsDiv = document.getElementById('profile-details');
      const fields = [
        { label: 'Display Name', value: user.display_name },
        { label: 'Username', value: user.username },
        { label: 'Email', value: user.email },
        { label: 'User ID', value: user.sub },
        { label: 'DID', value: user.did },
        { label: 'Agent Public Key', value: user.agent_pub_key }
      ];

      fields.forEach(field => {
        if (field.value) {
          const row = document.createElement('div');
          row.className = 'data-row';
          row.innerHTML = `
            <div class="data-label">${field.label}:</div>
            <div>${field.value}</div>
          `;
          detailsDiv.appendChild(row);
        }
      });
    }

    // Run on page load
    displayUser();
  </script>
</body>
</html>

How It Works

Step 1: Generate PKCE Challenge

javascript
// Create a random code verifier
const codeVerifier = generateRandomString(128);

// Hash it with SHA-256 and base64url encode
const codeChallenge = await generatePKCEChallenge(codeVerifier);

// Store verifier for later use
sessionStorage.setItem('pkce_code_verifier', codeVerifier);

Step 2: Redirect to Authorization

javascript
const authUrl = `https://auth-api.flowsta.com/oauth/authorize?
  client_id=${clientId}&
  redirect_uri=${redirectUri}&
  response_type=code&
  scope=openid email display_name&
  state=${state}&
  code_challenge=${codeChallenge}&
  code_challenge_method=S256`;

window.location.href = authUrl;

Step 3: Handle Callback

javascript
// Extract authorization code from URL
const code = new URLSearchParams(window.location.search).get('code');

// Exchange for tokens
const response = await fetch('https://auth-api.flowsta.com/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code: code,
    redirect_uri: redirectUri,
    client_id: clientId,
    code_verifier: codeVerifier // PKCE verification
  })
});

const tokens = await response.json();

Step 4: Get User Info

javascript
const userResponse = await fetch('https://auth-api.flowsta.com/oauth/userinfo', {
  headers: {
    'Authorization': `Bearer ${tokens.access_token}`
  }
});

const user = await userResponse.json();

Security Best Practices

✅ Do's

  • Always use PKCE - Never skip the code challenge
  • Validate state - Prevents CSRF attacks
  • Use HTTPS - Required for production
  • Store tokens securely - Never expose in URLs or localStorage for sensitive apps
  • Set appropriate scopes - Only request what you need

❌ Don'ts

  • Don't expose client secrets - Public apps shouldn't have secrets (that's why we use PKCE)
  • Don't store tokens in localStorage - SessionStorage is safer for single-page apps
  • Don't skip error handling - Always handle authorization errors gracefully
  • Don't trust URL parameters - Always validate state and other parameters

Production Considerations

For production applications, you should:

  1. Use your backend - Exchange authorization codes on your server, not in the browser
  2. Implement token refresh - Handle expired access tokens gracefully
  3. Add logout - Call the revocation endpoint when users log out
  4. Error tracking - Log authentication errors for debugging
  5. Loading states - Show proper UI feedback during OAuth flow

Next Steps

Documentation licensed under CC BY-SA 4.0.