authentication:send_sms
Table of Contents
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</font>
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 ====
<code>
''cat> /root/sms-otp-spi/pom.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.cotrav.keycloak</groupId>
<artifactId>keycloak-sms-otp</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<keycloak.version>26.1.0</keycloak.version>
</properties>
<dependencies>
<dependency><groupId>org.keycloak</groupId><artifactId>keycloak-server-spi</artifactId><version>${keycloak.version}</version><scope>provided</scope></dependency>
<dependency><groupId>org.keycloak</groupId><artifactId>keycloak-server-spi-private</artifactId><version>${keycloak.version}</version><scope>provided</scope></dependency>
<dependency><groupId>org.keycloak</groupId><artifactId>keycloak-services</artifactId><version>${keycloak.version}</version><scope>provided</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive><manifest><addClasspath>false</addClasspath></manifest></archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
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<ProviderConfigProperty> 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→ REQUIREDSMS 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 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.ftltemplate
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 <cutoff) otpSessions.delete(id);
}
}, 5 * 60 * 1000);
// ── 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 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:{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
authentication/send_sms.txt · Last modified: by sonali
