===== Overview ===== A system where users can log in using their **phone number + SMS OTP** instead of username/password, powered by Keycloak + Exotel. ===== Architecture ===== User (Browser/App) ↓ enter phone number Node.js Server (64.227.190.56:3000) ↓ phone → find username by Keycloak admin API ↓ Keycloak login form proxy Keycloak SPI (Java) ↓ OTP generate + store ↓ call webhook Node.js Webhook ↓ call Exotel API Exotel → SMS → User phone ↓ enter User OTP Keycloak verify OTP→ return JWT Token ===== Step 1: Created Custom Java SPI (Keycloak Plugin) ===== ===== Step 1: Connect to DigitalOcean Server ===== **1. Create Java SPI Project ** ==== Create folder structure ==== mkdir -p /root/sms-otp-spi/src/main/java/com/cotrav/keycloak\ mkdir -p /root/sms-otp-spi/src/main/resources/META-INF/services ==== File 1: pom.xml ==== ''cat> /root/sms-otp-spi/pom.xml <<'EOF' 4.0.0 com.cotrav.keycloak keycloak-sms-otp 1.0.0 jar 17 17 26.1.0 org.keycloakkeycloak-server-spi${keycloak.version}provided org.keycloakkeycloak-server-spi-private${keycloak.version}provided org.keycloakkeycloak-services${keycloak.version}provided org.apache.maven.plugins maven-jar-plugin false EOF **File 2: SmsOtpAuthenticator.java** cat> /root/sms-otp-spi/src/main/java/com/cotrav/keycloak/SmsOtpAuthenticatorFactory.java <<'EOF'\ package com.cotrav.keycloak;\ import org.keycloak.Config;\ import org.keycloak.authentication.Authenticator;\ import org.keycloak.authentication.AuthenticatorFactory;\ import org.keycloak.models.AuthenticationExecutionModel;\ import org.keycloak.models.KeycloakSession;\ import org.keycloak.models.KeycloakSessionFactory;\ import org.keycloak.provider.ProviderConfigProperty;\ import org.keycloak.provider.ProviderConfigurationBuilder;\ import java.util.List; public class SmsOtpAuthenticatorFactory implements AuthenticatorFactory {\ public static final String PROVIDER_ID = "sms-otp-authenticator";\ private static final SmsOtpAuthenticator SINGLETON = new SmsOtpAuthenticator(); @Override public String getId() { return PROVIDER_ID; }\ @Override public String getDisplayType() { return "SMS OTP Form"; }\ @Override public String getHelpText() { return "Sends OTP via SMS webhook and validates it."; }\ @Override public String getReferenceCategory() { return "otp"; }\ @Override public boolean isConfigurable() { return true; }\ @Override public boolean isUserSetupAllowed() { return false; } @Override\ public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {\ return new AuthenticationExecutionModel.Requirement[]{\ AuthenticationExecutionModel.Requirement.REQUIRED,\ AuthenticationExecutionModel.Requirement.ALTERNATIVE,\ AuthenticationExecutionModel.Requirement.DISABLED\ };\ } @Override\ public List getConfigProperties() {\ return ProviderConfigurationBuilder.create()\ .property()\ .name("smsWebhookUrl")\ .label("SMS Webhook URL")\ .helpText("URL of the webhook that sends SMS via Exotel")\ .type(ProviderConfigProperty.STRING_TYPE)\ .defaultValue("http://localhost:3000/webhook/send-sms")\ .add()\ .build();\ } @Override public Authenticator create(KeycloakSession session) { return SINGLETON; }\ @Override public void init(Config.Scope config) {}\ @Override public void postInit(KeycloakSessionFactory factory) {}\ @Override public void close() {}\ }\ EOF\ ==== File 4: Service registration file ==== cat> /root/sms-otp-spi/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory <<'EOF'\ com.cotrav.keycloak.SmsOtpAuthenticatorFactory\ EOF ===== Step 4: Build JAR ===== ''cd /root/sms-otp-spi docker run --rm -v "$PWD":/app -w /app maven:3.9-eclipse-temurin-17 mvn clean package -q ===== Step 5: Deploy JAR to Keycloak ===== docker cp /root/sms-otp-spi/target/keycloak-sms-otp-1.0.0.jar keycloak_app:/opt/keycloak/providers/ docker restart keycloak_app # Verify JAR is loaded docker exec keycloak_app ls /opt/keycloak/providers/ ==== ==== ==== ==== ===== Useful Commands ===== # Check Node.js is running ps aux | grep node # View server logs tail -f /root/keycloak-test/app.log # Restart Node.js pkill -f "node server.js" cd /root/keycloak-test && nohup node server.js>> app.log 2>&1 & # Restart Keycloak docker restart keycloak_app # Check Keycloak logs for errors docker logs keycloak_app --tail=50 2>&1 | grep -i "error\|sms" # Reload Nginx nginx -s reload ===== Step 2 : Configured Keycloak Admin Console ===== ==== 1 — Created Authentication Flow ==== * Admin Console → **cotrav-OPS** realm → Authentication → Create Flow * Flow name: ''Browser SMS OTP'' * Added steps: * ''Username Form'' → REQUIRED * ''SMS OTP Form'' (custom SPI) → REQUIRED ==== 2 — Set Webhook URL in Flow ==== * Clicked gear icon ⚙️ on "SMS OTP Form" step * Set ''smsWebhookUrl'' = ''[[http://172.17.0.1:3000/webhook/send-sms|http://172.17.0.1:3000/webhook/send-sms]]'' * ''172.17.0.1'' = Docker host gateway IP (Keycloak container → host Node.js) ==== 3 — Created sms-login-client ==== * Admin Console → Clients → Create * ''Client ID'' = ''sms-login-client'' * Authentication Flow Override → Browser = ''Browser SMS OTP'' * Valid Redirect URIs = ''*'' ==== 4 — Added phone to User Profile ⬅️ (This step was done manually) ==== * Admin Console → **cotrav-OPS** realm → **Realm Settings** * Click **User Profile** tab * Click **Add attribute** * Attribute name: ''phone'' * Save ==== 5 — Set Phone Number on User ⬅️ (Done manually) ==== * Admin Console → Users → ''sonali.magar'' * Click **Attributes** tab * Add: ''phone'' = ''8999463315'' * Save ==== 6 — Set Login Theme ==== * Realm Settings → Themes → **Login Theme** = ''cotrav'' * Required so Keycloak finds ''sms-otp-form.ftl'' template ===== Step 3 : Built Node.js Server ===== **File:**''server.js'' — hosted on DigitalOcean at ''/root/keycloak-test/'' ''POST /api/login-sms'' 1. Receive: { kcUrl, realm, phone } 2. Get Keycloak admin token 3. Search users by phone attribute → get username 4. Fetch sms-login-client login form from Keycloak 5. POST username to Keycloak → SPI runs → webhook called → SMS sent 6. Save OTP form session → return { requiresOTP: true, sessionId } === POST /api/verify-sms-otp === 1. Receive: { sessionId, otp } 2. POST otp to Keycloak's OTP form action URL 3. Keycloak verifies OTP → returns auth code 4. Exchange code for JWT tokens → return to client === POST /webhook/send-sms (called by Keycloak SPI) === 1. Receive: { phone, otp, name } 2. Normalize phone: 8999463315 → +918999463315 3. Build message: "Dear {name}, OTP for login to your Cotrav Hotel Agent App Is {otp} Regards, Cotrav" 4. Call Exotel API → SMS delivered ==== Key settings in server.js: ==== app.set('trust proxy', 1); // Trust Nginx HTTPS headers app.use(cors({ origin: '*' })); // Allow requests from any origin // Exotel config sid = 'SID' token = 'token' from = 'DLT approved sender ID' **Steps on digital Ocean** **1. Install Node.js** curl -fsSL https://rpm.nodesource.com/setup_20.x | bash - dnf install -y nodejs node -v # verify installation **2 : Create Node.js Project** mkdir -p /root/keycloak-test cd /root/keycloak-test npm init -y npm install express cors axios 3. Start Server nohup node server.js>> app.log 2>&1 & ===== Step 4: Configured Nginx as Reverse Proxy ===== DigitalOcean firewall blocked port 3000. Added Node.js routes to existing Nginx config at ''/etc/nginx/conf.d/keycloak.conf'': location /api/ { proxy_pass http://localhost:3000; proxy_set_header X-Forwarded-Proto $scheme; } location /webhook/ { proxy_pass http://localhost:3000; proxy_set_header X-Forwarded-Proto $scheme; } nginx -s reload sample server.js ## Complete server.js Code javascript const express = require('express'); const cors = require('cors'); const axios = require('axios'); const https = require('https'); const qs = require('querystring'); const path = require('path'); const app = express(); app.set('trust proxy', 1); app.use(cors({ origin: '*' })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); const httpsAgent = new https.Agent({ rejectUnauthorized: false }); const otpSessions = new Map(); // Session cleanup (every 5 min, TTL 10 min) setInterval(() => { const cutoff = Date.now() - 10 * 60 * 1000; for (const [id, session] of otpSessions) { if (session.createdAt 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 Login ───────────────────────────────────────────────────────────── 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.' }); // Step 1: Find username by phone via Keycloak 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; 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 match = usersRes.data.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) { return res.status(500).json({ error: `Failed to look up user: ${err.response?.data?.error_description || err.message}` }); } // Step 2: Trigger Keycloak SMS OTP flow const redirectUri = `${req.protocol}://${req.get('host')}/callback`; const authUrl = `${kcUrl}/realms/${realm}/protocol/openid-connect/auth`; try { 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 actionMatch = authRes.data.match(/action="([^"]+)"/); if (!actionMatch) return res.status(500).json({ error: 'Could not find login form.' }); const loginActionUrl = actionMatch[1].replace(/&/g, '&'); const loginCookies = parseCookies(authRes.headers['set-cookie']); 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 (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 }); } 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'])); } const otpActionMatch = otpHtml?.match(/action="([^"]+)"/); if (!otpActionMatch) return res.status(500).json({ error: 'SMS OTP form not found. Check sms-login-client flow and cotrav theme.' }); const sessionId = generateSessionId(); otpSessions.set(sessionId, { otpActionUrl: otpActionMatch[1].replace(/&/g, '&'), cookies: otpCookies, kcUrl, realm, clientId: smsClientId, redirectUri, createdAt: Date.now(), }); return res.json({ requiresOTP: true, sessionId }); } catch (err) { return res.status(500).json({ error: `SMS login failed: ${err.response?.data ? JSON.stringify(err.response.data) : err.message}` }); } }); // ── SMS OTP Verify ──────────────────────────────────────────────────────────── 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.' }); 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. 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}` }); } }); // ── Webhook — called by Keycloak SPI to send SMS via Exotel ────────────────── app.post('/webhook/send-sms', async (req, res) => { let { phone, otp, name } = req.body; if (!phone || !otp) return res.status(400).json({ error: 'phone and otp required' }); // Normalize phone to +91XXXXXXXXXX if (!phone.startsWith('+')) phone = '+91' + phone.replace(/^0+/, ''); const sid = process.env.EXOTEL_SID || 'actual sid'; const token = process.env.EXOTEL_API_TOKEN || 'actual token'; const from = process.env.EXOTEL_FROM || 'actual id'; const message = `Dear ${name || 'User'},\n\nOTP for login to your Cotrav Hotel Agent App Is ${otp}\n\nRegards,\nCotrav`; try { const url = `https://${sid}:${token}@twilix.exotel.in/v1/Accounts/${sid}/Sms/send`; const smsRes = await axios.post(url, qs.stringify({ From: from, To: phone, Body: message }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, validateStatus: s => s <600, }); console.log('[Exotel] status:', smsRes.status); return res.json({ success: true }); } catch (err) { return res.status(500).json({ error: `Exotel error: ${err.message}` }); } }); // ── Start Server ────────────────────────────────────────────────────────────── app.use(express.static(path.join(__dirname, 'public'))); app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'keycloak-test.html'))); app.listen(3000, () => console.log('Server running on http://localhost:3000')); ==== Upload server.js from local machine (run on Windows): ==== scp d:/keycloak-test/server.js root@64.227.190.56:/root/keycloak-test/server.js ==== Start Node.js server: ==== cd /root/keycloak-test nohup node server.js>> app.log 2>&1 & # Check it started tail -5 app.log **Step 7: Update Nginx Config** cat> /etc/nginx/conf.d/keycloak.conf <<'EOF' server { listen 443 ssl; server_name 64.227.190.56; ssl_certificate /etc/nginx/ssl/keycloak.crt; ssl_certificate_key /etc/nginx/ssl/keycloak.key; add_header Strict-Transport-Security "max-age=31536000" always; add_header X-Frame-Options SAMEORIGIN; add_header X-Content-Type-Options nosniff; # Node.js API location /api/ { proxy_pass http://localhost:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } # Webhook location /webhook/ { proxy_pass http://localhost:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } # Keycloak\ location / { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } } server { listen 80; server_name 64.227.190.56; return 301 https://$host$request_uri; } EOF **# Test and reload Nginx** nginx -t && nginx -s reload **Step 8:Exotel SMS Configuration** * **Provider:** Exotel * **Account SID:**''novuslogic1'' * **Sender ID:**''COTRAV'' (DLT approved) * **API endpoint:**''[[https://novuslogic1|https://novuslogic1]]:{token}@twilix.exotel.in/v1/Accounts/novuslogic1/Sms/send'' * **Approved message template:** Dear {name}, OTP for login to your Cotrav Hotel Agent App Is {otp} Regards, Cotrav