User Tools

Site Tools


send_email_through_keyclock

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Both sides previous revisionPrevious revision
Next revision
Previous revision
send_email_through_keyclock [2026/03/09 07:54] sonalisend_email_through_keyclock [2026/03/09 08:00] (current) sonali
Line 1: Line 1:
 + <font 16px/inherit;;inherit;;inherit>**Overview**</font>
  
 +A system where users can log in using their **email Id /Username + Email OTP** instead of username/password, by Keycloak.
  
 **Make Email OTP Java SPI ** **Make Email OTP Java SPI **
Line 168: Line 170:
 - No required user action available in user details\\ - No required user action available in user details\\
 - Set Email & password - Set Email & password
- 
-Sample app backend code 
- 
-<code> 
- 
-const express = require('express'); 
- 
-const cors = require('cors'); 
- 
-const axios = require('axios'); 
- 
-const https = require('https'); 
- 
-const qs = require('querystring'); 
- 
-const app = express(); 
- 
-app.set('trust proxy', 1); // trust Nginx X-Forwarded-Proto 
- 
-app.use(cors({ origin: '*' })); 
- 
-app.use(express.json()); 
- 
-app.use(express.urlencoded({ extended: true })); 
- 
-// Allow self-signed certs (common for dev Keycloak instances) 
- 
-const httpsAgent = new https.Agent({ rejectUnauthorized: false }); 
- 
-// In-memory OTP session store (use Redis in production) 
- 
-const otpSessions = new Map(); 
- 
-// Clean up expired sessions every 5 minutes (TTL = 10 minutes) 
- 
-setInterval(() => { 
- 
-const cutoff = Date.now() - 10 * 60 * 1000; 
- 
-for (const [id, session] of otpSessions) { 
- 
-  if (session.createdAt < cutoff) otpSessions.delete(id); 
- 
-} 
- 
-}, 5 * 60 * 1000); 
- 
-/** 
- 
-* Step 1: Initiate Keycloak email OTP flow 
- 
-* 
- 
-* 1. Fetch Keycloak login form → extract action URL + cookies 
- 
-* 2. POST credentials to Keycloak → Keycloak sends OTP email, returns OTP form 
- 
-* 3. Extract OTP form action URL, store session, return { requiresOTP, sessionId } 
- 
-*/ 
- 
-app.post('/api/login', async (req, res) => { 
- 
-const { kcUrl, realm, clientId, username } = req.body; 
- 
-if (!kcUrl || !realm || !clientId || !username) { 
- 
-  return res.status(400).json({ error: 'All fields are required.' }); 
- 
-} 
- 
-const redirectUri = `${req.protocol}://${req.get('host')}/callback`; 
- 
-const authUrl = `${kcUrl}/realms/${realm}/protocol/openid-connect/auth`; 
- 
-try { 
- 
-  // 1. GET Keycloak login page 
- 
-  const authRes = await axios.get(authUrl, { 
- 
-    params: { 
- 
-      client_id: clientId, 
- 
-      response_type: 'code', 
- 
-      scope: 'openid', 
- 
-      redirect_uri: redirectUri, 
- 
-    }, 
- 
-    httpsAgent, 
- 
-    maxRedirects: 5, 
- 
-    validateStatus: s => s < 500, 
- 
-  }); 
- 
-  if (authRes.status !== 200) { 
- 
-    // Extract Keycloak's error detail from the response body 
- 
-    let kcDetail = ''; 
- 
-    try { 
- 
-      const body = typeof authRes.data === 'string' ? authRes.data : JSON.stringify(authRes.data); 
- 
-      const errMatch = body.match(/error[^<]{0,200}/i); 
- 
-      kcDetail = errMatch ? ' | Keycloak says: ' + errMatch[0].replace(/<[^>]+>/g, '').trim().substring(0, 150) : ''; 
- 
-    } catch (_) {} 
- 
-    return res.status(400).json({ 
- 
-      error: `Keycloak HTTP ${authRes.status} at ${authUrl}.${kcDetail}`, 
- 
-    }); 
- 
-  } 
- 
-  // Extract form action URL and session cookies 
- 
-  const loginHtml = authRes.data; 
- 
-  const actionMatch = loginHtml.match(/action="([^"]+)"/); 
- 
-  if (!actionMatch) { 
- 
-    return res.status(500).json({ error: 'Could not find login form in Keycloak response.' }); 
- 
-  } 
- 
-  const loginActionUrl = actionMatch[1].replace(/&amp;/g, '&'); 
- 
-  const loginCookies = parseCookies(authRes.headers['set-cookie']); 
- 
-  // 2. POST credentials to Keycloak 
- 
-  const credRes = await axios.post( 
- 
-    loginActionUrl, 
- 
-    qs.stringify({ username }), 
- 
-    { 
- 
-      headers: { 
- 
-        'Content-Type': 'application/x-www-form-urlencoded', 
- 
-        'Cookie': loginCookies, 
- 
-      }, 
- 
-      httpsAgent, 
- 
-      maxRedirects: 0, 
- 
-      validateStatus: s => s < 500, 
- 
-    } 
- 
-  ); 
- 
-  // If Keycloak redirected with code → OTP not required, direct login success 
- 
-  if (credRes.status === 302 && credRes.headers.location?.includes('code=')) { 
- 
-    const code = new URL(credRes.headers.location).searchParams.get('code'); 
- 
-    const tokens = await exchangeCodeForTokens(kcUrl, realm, clientId, code, redirectUri); 
- 
-    return res.json({ success: true, tokens }); 
- 
-  } 
- 
-  // Log what Keycloak actually returned for debugging 
- 
-  console.log('credRes.status:', credRes.status); 
- 
-  console.log('credRes.location:', credRes.headers.location); 
- 
-  // If Keycloak returned an error page (wrong credentials etc.) 
- 
-  if (credRes.status === 200 && credRes.data?.includes('Invalid username or password')) { 
- 
-    return res.status(401).json({ error: 'Invalid username or password.' }); 
- 
-  } 
- 
-  // Keycloak may redirect (302) to the OTP form page — follow it 
- 
-  let otpHtml = credRes.data; 
- 
-  let otpCookies = mergeCookies(loginCookies, parseCookies(credRes.headers['set-cookie'])); 
- 
-  if (credRes.status === 302 && credRes.headers.location && !credRes.headers.location.includes('code=')) { 
- 
-    const redirectUrl = credRes.headers.location; 
- 
-    const otpPageRes = await axios.get(redirectUrl, { 
- 
-      headers: { 'Cookie': otpCookies }, 
- 
-      httpsAgent, 
- 
-      maxRedirects: 5, 
- 
-      validateStatus: s => s < 500, 
- 
-    }); 
- 
-    otpHtml = otpPageRes.data; 
- 
-    otpCookies = mergeCookies(otpCookies, parseCookies(otpPageRes.headers['set-cookie'])); 
- 
-  } 
- 
-  // 3. OTP form should be present — extract its action URL 
- 
-  const otpActionMatch = otpHtml?.match(/action="([^"]+)"/); 
- 
-  if (!otpActionMatch) { 
- 
-    return res.status(500).json({ 
- 
-      error: 'Unexpected Keycloak response after credential submission. Email OTP may not be configured.', 
- 
-    }); 
- 
-  } 
- 
-  const otpActionUrl = otpActionMatch[1].replace(/&amp;/g, '&'); 
- 
-  const allCookies = otpCookies; 
- 
-  // Store session for OTP verification step 
- 
-  const sessionId = generateSessionId(); 
- 
-  otpSessions.set(sessionId, { 
- 
-    otpActionUrl, 
- 
-    cookies: allCookies, 
- 
-    kcUrl, realm, clientId, redirectUri, 
- 
-    createdAt: Date.now(), 
- 
-  }); 
- 
-  return res.json({ requiresOTP: true, sessionId }); 
- 
-} catch (err) { 
- 
-  const msg = err.response?.data || err.message; 
- 
-  return res.status(500).json({ error: `Login failed: ${msg}` }); 
- 
-} 
- 
-}); 
- 
-/** 
- 
-* Step 2: Verify OTP 
- 
-* 
- 
-* POST the OTP to Keycloak's OTP action URL, get authorization code, 
- 
-* exchange for tokens. 
- 
-*/ 
- 
-app.post('/api/verify-otp', async (req, res) => { 
- 
-const { sessionId, otp } = req.body; 
- 
-if (!sessionId || !otp) { 
- 
-  return res.status(400).json({ error: 'sessionId and otp are required.' }); 
- 
-} 
- 
-const session = otpSessions.get(sessionId); 
- 
-if (!session) { 
- 
-  return res.status(400).json({ error: 'Session not found or expired. Please login again.' }); 
- 
-} 
- 
-try { 
- 
-  const otpRes = await axios.post( 
- 
-    session.otpActionUrl, 
- 
-    qs.stringify({ 'email-otp': otp }), 
- 
-    { 
- 
-      headers: { 
- 
-        'Content-Type': 'application/x-www-form-urlencoded', 
- 
-        'Cookie': session.cookies, 
- 
-      }, 
- 
-      httpsAgent, 
- 
-      maxRedirects: 0, 
- 
-      validateStatus: s => s < 500, 
- 
-    } 
- 
-  ); 
- 
-  // Success: Keycloak redirects with authorization code 
- 
-  if (otpRes.status === 302 && otpRes.headers.location?.includes('code=')) { 
- 
-    const code = new URL(otpRes.headers.location).searchParams.get('code'); 
- 
-    const { kcUrl, realm, clientId, redirectUri } = session; 
- 
-    const tokens = await exchangeCodeForTokens(kcUrl, realm, clientId, code, redirectUri); 
- 
-    otpSessions.delete(sessionId); 
- 
-    return res.json({ success: true, tokens }); 
- 
-  } 
- 
-  // Invalid OTP — Keycloak returns the OTP form again 
- 
-  if (otpRes.status === 200) { 
- 
-    return res.status(401).json({ error: 'Invalid OTP code. Please try again.' }); 
- 
-  } 
- 
-  return res.status(400).json({ error: `Unexpected response: HTTP ${otpRes.status}` }); 
- 
-} catch (err) { 
- 
-  return res.status(500).json({ error: `OTP verification failed: ${err.message}` }); 
- 
-} 
- 
-}); 
- 
-/** 
- 
-* Direct password login (no OTP) — uses Resource Owner Password Credentials grant 
- 
-*/ 
- 
-app.post('/api/login-password', async (req, res) => { 
- 
-const { kcUrl, realm, clientId, username, password } = req.body; 
- 
-if (!kcUrl || !realm || !clientId || !username || !password) { 
- 
-  return res.status(400).json({ error: 'All fields are required.' }); 
- 
-} 
- 
-try { 
- 
-  const tokenUrl = `${kcUrl}/realms/${realm}/protocol/openid-connect/token`; 
- 
-  const response = await axios.post( 
- 
-    tokenUrl, 
- 
-    qs.stringify({ grant_type: 'password', client_id: clientId, username, password }), 
- 
-    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, httpsAgent } 
- 
-  ); 
- 
-  return res.json({ success: true, tokens: response.data }); 
- 
-} catch (err) { 
- 
-  const msg = err.response?.data?.error_description || err.response?.data?.error || err.message; 
- 
-  return res.status(401).json({ error: `Login failed: ${msg}` }); 
- 
-} 
- 
-}); 
- 
-// ── Helpers ────────────────────────────────────────────────────────────────── 
- 
-async function exchangeCodeForTokens(kcUrl, realm, clientId, code, redirectUri) { 
- 
-const tokenUrl = `${kcUrl}/realms/${realm}/protocol/openid-connect/token`; 
- 
-const res = await axios.post( 
- 
-  tokenUrl, 
- 
-  qs.stringify({ grant_type: 'authorization_code', client_id: clientId, code, redirect_uri: redirectUri }), 
- 
-  { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, httpsAgent } 
- 
-); 
- 
-return res.data; 
- 
-} 
- 
-function parseCookies(setCookieHeader) { 
- 
-if (!setCookieHeader) return ''; 
- 
-const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; 
- 
-return arr.map(c => c.split(';')[0]).join('; '); 
- 
-} 
- 
-function mergeCookies(existing, incoming) { 
- 
-const parts = [...new Set([ 
- 
-  ...existing.split('; ').filter(Boolean), 
- 
-  ...incoming.split('; ').filter(Boolean), 
- 
-])]; 
- 
-return parts.join('; '); 
- 
-} 
- 
-function generateSessionId() { 
- 
-return Math.random().toString(36).slice(2) + Date.now().toString(36); 
- 
-} 
- 
-// ── SMS OTP — proxied through Keycloak (OTP stored in Keycloak auth session) ── 
- 
-async function sendExotelSms(sid, token, from, to, message) { 
- 
-const url = `https://${sid}:${token}@twilix.exotel.in/v1/Accounts/${sid}/Sms/send`; 
- 
-const res = await axios.post(url, qs.stringify({ From: from, To: to, Body: message }), { 
- 
-  headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 
- 
-  validateStatus: s => s < 600, 
- 
-}); 
- 
-console.log('[Exotel] status:', res.status, 'response:', typeof res.data === 'string' ? res.data.substring(0, 500) : JSON.stringify(res.data)); 
- 
-return res; 
- 
-} 
- 
-/** 
- 
-* SMS OTP Step 1: Initiate Keycloak SMS OTP flow via sms-login-client 
- 
-* Keycloak generates & stores the OTP — app never sees it. 
- 
-* Keycloak calls /webhook/send-sms to deliver the SMS. 
- 
-*/ 
- 
-app.post('/api/login-sms', async (req, res) => { 
- 
-const { kcUrl, realm, phone } = req.body; 
- 
-const smsClientId = 'sms-login-client'; 
- 
-if (!kcUrl || !realm || !phone) { 
- 
-  return res.status(400).json({ error: 'kcUrl, realm, and phone are required.' }); 
- 
-} 
- 
-// Look up username by phone attribute via admin API 
- 
-let username; 
- 
-try { 
- 
-  const tokenRes = await axios.post( 
- 
-    `${kcUrl}/realms/master/protocol/openid-connect/token`, 
- 
-    qs.stringify({ grant_type: 'password', client_id: 'admin-cli', username: 'super.admin', password: 'SuperAdmin@26' }), 
- 
-    { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, httpsAgent } 
- 
-  ); 
- 
-  const adminToken = tokenRes.data.access_token; 
- 
-  // Normalize phone for search 
- 
-  const normalizedPhone = phone.startsWith('+91') ? phone.slice(3) : phone.replace(/^0/, ''); 
- 
-  const usersRes = await axios.get( 
- 
-    `${kcUrl}/admin/realms/${realm}/users`, 
- 
-    { params: { q: `phone:${normalizedPhone}`, max: 5 }, headers: { Authorization: `Bearer ${adminToken}` }, httpsAgent } 
- 
-  ); 
- 
-  const users = usersRes.data; 
- 
-  const match = users.find(u => { 
- 
-    const p = (u.attributes?.phone?.[0] || '').replace(/^\+91/, '').replace(/^0/, ''); 
- 
-    return p === normalizedPhone; 
- 
-  }); 
- 
-  if (!match) { 
- 
-    return res.status(404).json({ error: 'No user found with this phone number.' }); 
- 
-  } 
- 
-  username = match.username; 
- 
-} catch (err) { 
- 
-  const msg = err.response?.data?.error_description || err.message; 
- 
-  return res.status(500).json({ error: `Failed to look up user: ${msg}` }); 
- 
-} 
- 
-const redirectUri = `${req.protocol}://${req.get('host')}/callback`; 
- 
-const authUrl = `${kcUrl}/realms/${realm}/protocol/openid-connect/auth`; 
- 
-try { 
- 
-  // 1. GET Keycloak login page for sms-login-client 
- 
-  const authRes = await axios.get(authUrl, { 
- 
-    params: { client_id: smsClientId, response_type: 'code', scope: 'openid', redirect_uri: redirectUri }, 
- 
-    httpsAgent, maxRedirects: 5, validateStatus: s => s < 500, 
- 
-  }); 
- 
-  if (authRes.status !== 200) { 
- 
-    return res.status(400).json({ error: `Keycloak returned HTTP ${authRes.status} — is sms-login-client created?` }); 
- 
-  } 
- 
-  const loginHtml = authRes.data; 
- 
-  const actionMatch = loginHtml.match(/action="([^"]+)"/); 
- 
-  if (!actionMatch) { 
- 
-    return res.status(500).json({ error: 'Could not find login form in Keycloak response.' }); 
- 
-  } 
- 
-  const loginActionUrl = actionMatch[1].replace(/&amp;/g, '&'); 
- 
-  const loginCookies = parseCookies(authRes.headers['set-cookie']); 
- 
-  // 2. POST username → Keycloak triggers SMS OTP flow, calls webhook, sends SMS 
- 
-  const credRes = await axios.post( 
- 
-    loginActionUrl, 
- 
-    qs.stringify({ username }), 
- 
-    { 
- 
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': loginCookies }, 
- 
-      httpsAgent, maxRedirects: 0, validateStatus: s => s < 500, 
- 
-    } 
- 
-  ); 
- 
-  // Direct code (no OTP step — should not happen with SMS flow) 
- 
-  if (credRes.status === 302 && credRes.headers.location?.includes('code=')) { 
- 
-    const code = new URL(credRes.headers.location).searchParams.get('code'); 
- 
-    const tokens = await exchangeCodeForTokens(kcUrl, realm, smsClientId, code, redirectUri); 
- 
-    return res.json({ success: true, tokens }); 
- 
-  } 
- 
-  // Follow redirect to OTP form if needed 
- 
-  let otpHtml = typeof credRes.data === 'string' ? credRes.data : JSON.stringify(credRes.data); 
- 
-  let otpCookies = mergeCookies(loginCookies, parseCookies(credRes.headers['set-cookie'])); 
- 
-  if (credRes.status === 302 && credRes.headers.location && !credRes.headers.location.includes('code=')) { 
- 
-    const otpPageRes = await axios.get(credRes.headers.location, { 
- 
-      headers: { 'Cookie': otpCookies }, 
- 
-      httpsAgent, maxRedirects: 5, validateStatus: s => s < 500, 
- 
-    }); 
- 
-    otpHtml = typeof otpPageRes.data === 'string' ? otpPageRes.data : JSON.stringify(otpPageRes.data); 
- 
-    otpCookies = mergeCookies(otpCookies, parseCookies(otpPageRes.headers['set-cookie'])); 
- 
-  } 
- 
-  // Debug: log what Keycloak returned 
- 
-  console.log('[SMS] credRes.status:', credRes.status, 'location:', credRes.headers.location); 
- 
-  // console.log('[SMS] otpHtml (first 800):', otpHtml?.substring(0, 800)); 
- 
-  // 3. Extract OTP form action URL 
- 
-  const otpActionMatch = otpHtml?.match(/action="([^"]+)"/); 
- 
-  if (!otpActionMatch) { 
- 
-    return res.status(500).json({ 
- 
-      error: 'SMS OTP form not found. Ensure sms-login-client has Browser SMS OTP flow configured and cotrav theme is set.', 
- 
-    }); 
- 
-  } 
- 
-  const otpActionUrl = otpActionMatch[1].replace(/&amp;/g, '&'); 
- 
-  const sessionId = generateSessionId(); 
- 
-  otpSessions.set(sessionId, { 
- 
-    otpActionUrl, cookies: otpCookies, 
- 
-    kcUrl, realm, clientId: smsClientId, redirectUri, 
- 
-    createdAt: Date.now(), 
- 
-  }); 
- 
-  return res.json({ requiresOTP: true, sessionId }); 
- 
-} catch (err) { 
- 
-  const msg = err.response?.data ? JSON.stringify(err.response.data) : err.message; 
- 
-  return res.status(500).json({ error: `SMS login failed: ${msg}` }); 
- 
-} 
- 
-}); 
- 
-/** 
- 
-* SMS OTP Step 2: Submit OTP to Keycloak — Keycloak verifies it 
- 
-*/ 
- 
-app.post('/api/verify-sms-otp', async (req, res) => { 
- 
-const { sessionId, otp } = req.body; 
- 
-if (!sessionId || !otp) return res.status(400).json({ error: 'sessionId and otp are required.' }); 
- 
-const session = otpSessions.get(sessionId); 
- 
-if (!session) return res.status(400).json({ error: 'Session not found or expired. Please login again.' }); 
- 
-try { 
- 
-  const otpRes = await axios.post( 
- 
-    session.otpActionUrl, 
- 
-    qs.stringify({ sms_otp: otp }), 
- 
-    { 
- 
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': session.cookies }, 
- 
-      httpsAgent, maxRedirects: 0, validateStatus: s => s < 500, 
- 
-    } 
- 
-  ); 
- 
-  if (otpRes.status === 302 && otpRes.headers.location?.includes('code=')) { 
- 
-    const code = new URL(otpRes.headers.location).searchParams.get('code'); 
- 
-    const { kcUrl, realm, clientId, redirectUri } = session; 
- 
-    const tokens = await exchangeCodeForTokens(kcUrl, realm, clientId, code, redirectUri); 
- 
-    otpSessions.delete(sessionId); 
- 
-    return res.json({ success: true, tokens }); 
- 
-  } 
- 
-  if (otpRes.status === 200) { 
- 
-    return res.status(401).json({ error: 'Invalid OTP code. Please try again.' }); 
- 
-  } 
- 
-  return res.status(400).json({ error: `Unexpected response: HTTP ${otpRes.status}` }); 
- 
-} catch (err) { 
- 
-  return res.status(500).json({ error: `SMS OTP verification failed: ${err.message}` }); 
- 
-} 
- 
-}); 
- 
-// ── Exotel Webhook (called by Keycloak SMS SPI) ─────────────────────────────── 
- 
-app.post('/webhook/send-sms', async (req, res) => { 
- 
-let { phone, otp } = req.body; 
- 
-if (!phone || !otp) return res.status(400).json({ error: 'phone and otp required' }); 
- 
-// Normalize: ensure +91 prefix for Indian numbers 
- 
-if (!phone.startsWith('+')) { 
- 
-  phone = '+91' + phone.replace(/^0+/, ''); 
- 
-} 
- 
-const sid   = process.env.EXOTEL_SID   || 'novuslogic1'; 
- 
-const token = process.env.EXOTEL_API_TOKEN || '94bfed570a17cd98d466175e1c893ad3cf5aef03'; 
- 
-const from  = process.env.EXOTEL_FROM  || 'COTRAV'; 
- 
-if (!sid || !token || !from) { 
- 
-  return res.status(500).json({ error: 'Exotel credentials not configured on server.' }); 
- 
-} 
- 
-const { name } = req.body; 
- 
-const userName = name || 'User'; 
- 
-const message = `Dear ${userName},\n\nOTP for login to your Cotrav Hotel Agent App Is ${otp}\n\nRegards,\nCotrav`; 
- 
-try { 
- 
-  await sendExotelSms(sid, token, from, phone, message); 
- 
-  return res.json({ success: true }); 
- 
-} catch (err) { 
- 
-  const msg = err.response?.data ? JSON.stringify(err.response.data) : err.message; 
- 
-  return res.status(500).json({ error: `Exotel error: ${msg}` }); 
- 
-} 
- 
-}); 
- 
-// ── Static + Start ──────────────────────────────────────────────────────────── 
- 
-const path = require('path'); 
- 
-app.use(express.static(path.join(__dirname, 'public'))); 
- 
-// Redirect root to the main page 
- 
-app.get('/', (_req, res) => { 
- 
-res.sendFile(path.join(__dirname, 'public', 'keycloak-test.html')); 
- 
-}); 
- 
-app.listen(3000, () => console.log('Server running on http://localhost:3000')); 
- 
- 
-</code> 
  
  
send_email_through_keyclock.1773042885.txt.gz · Last modified: by sonali