User Tools

Site Tools


send_email_through_keyclock

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'));
send_email_through_keyclock.1773043081.txt.gz · Last modified: by sonali