===== 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