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:
- Use your backend - Exchange authorization codes on your server, not in the browser
- Implement token refresh - Handle expired access tokens gracefully
- Add logout - Call the revocation endpoint when users log out
- Error tracking - Log authentication errors for debugging
- Loading states - Show proper UI feedback during OAuth flow
Next Steps
- OAuth API Reference
- Security Best Practices
- SDK Documentation (for easier integration)