This is an old revision of the document!
Make Email OTP Java SPI
# Run this to find the Keycloak container:
docker ps | grep -i keycloak Result will look like 2550aa1a95b7 quay.io/keycloak/keycloak:26.1.0 "/opt/keycloak/bin/k…" 7 days ago Up 7 days 8443/tcp, 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp, 9000/tcp keycloak_app fcca38958118 postgres:16 "docker-entrypoint.s…" 7 days ago Up 7 days (healthy) 5432/tcp
Run this in the droplet console to find the exact JAR download URL:
curl -s https://api.github.com/repos/for-keycloak/email-otp-authenticator/releases/latest | grep browser_download_url
We need email-otp-authenticator JAR if it is not available
1. Download the email-otp-authenticator JAR
curl -L -o email-otp-authenticator.jar https://github.com/for-keycloak/email-otp-authenticator/releases/download/v1.3.5/email-otp-authenticator-v1.3.5-kc-26.2.5.jar
2. Copy into the running container
docker cp email-otp-authenticator.jar keycloak_app:/opt/keycloak/providers/
3. Run build inside the container (registers the provider)
# Verify it's there docker exec keycloak_app ls /opt/keycloak/providers/
docker exec keycloak_app /opt/keycloak/bin/kc.sh build
4. Restart the container
docker restart keycloak_app
# Now let's set up the Email OTP flow. Go to Keycloak Admin Console at https://64.227.190.56/:
1. First configure SMTP (if not already done)
Realm Settings → Email
Host: smtp.gmail.com,
Port: 587
From: from email id
Username: your username,
Password: your app
password Enable StartTLS → Save → Test connection
2. Create Email OTP Authentication Flow
Go to Authentication → Flows → Create flow Name: Browser Email OTP → Save Add step → Username Password Form → Required Add step → Email OTP → Required
3. Bind the flow
Client → account → Advance Override realm authentication flow bindings. →Browser Flow → Browser email otp
Customize email content
python3 -c "import zipfile; [print(f) for f in zipfile.ZipFile('email-otp-authenticator.jar').namelist()]"
# check current email template
python3 -c "
import zipfile
with zipfile.ZipFile('email-otp-authenticator.jar') as z:
print(z.read('theme-resources/messages/messages_en.properties').decode())
"
# To customize the email text, create a custom Keycloak theme. Run these commands on the droplet:
Step 1: Create theme directory \structure
docker exec keycloak_app mkdir -p /opt/keycloak/themes/cotrav/email/messages
Step 2: Create theme.\properties
docker exec keycloak_app sh -c 'cat> /opt/keycloak/themes/cotrav/email/theme.properties <<\EOF parent=\base EOF'
Step 3: Create custom messages (edit the text as you like)
docker exec keycloak_app sh -c 'cat> /opt/keycloak/themes/cotrav/email/messages/messages_en.properties <<\EOF
emailOtpSubject=Your Cotrav OTP \Code
emailOtpYourAccessCode=Your one-time login code is:
emailOtpExpiration=This code will expire in {0} minutes. Do not share it with anyone.
EOF'
Step 4: Set realm to use this \theme
TOKEN=$(curl -s -X POST http://64.227.190.56:8080/realms/master/protocol/openid-connect/token
-d "client_id=admin-cli&grant_type=password&username=superadmin_username&password=superadminpassword"
| grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
curl -s -X PUT http://64.227.190.56:8080/admin/realms/master \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"emailTheme":"cotrav"}'
Then test by logging in again — you should see your custom text in the OTP email.
# Invalid otp issue
The OTP field name sent by our server might not match what the extension expects. Let me check:
python3 -c "\
import zipfile\
with zipfile.ZipFile('email-otp-authenticator.jar') as z:\
print(z.read('theme-resources/templates/login-email-otp.ftl').decode())\
"
# Browser email otp Flow order should be
Username Form → Required (first)
Email OTP Form → Required (second)
# Dont do this
- No required user action available in user details
- Set Email & password
Sample app backend 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(/&/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(/&/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(/&/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(/&/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'));
