User Tools

Site Tools


authentication:send_sms

This is an old revision of the document!


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 → REQUIRED
    • SMS OTP Form (custom SPI) → REQUIRED

2 — Set Webhook URL in Flow

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

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

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       || 'novuslogic1';

  const token = process.env.EXOTEL_API_TOKEN  || '94bfed570a17cd98d466175e1c893ad3cf5aef03';

  const from  = process.env.EXOTEL_FROM       || 'COTRAV';

  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'));
authentication/send_sms.1773041114.txt.gz · Last modified: by sonali