Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,8 @@ src/main/environment/admin_local.properties
node_modules

# Logs
logs/
logs/
*.api-results.csv
test-results/
playwright-report/
playwright/.cache/
40 changes: 40 additions & 0 deletions nikshay-enroll/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Ni-kshay login worker (Admin-API)

Node + Playwright worker that logs in to the Ni-kshay portal in a real browser and
prints the portal's session cookies as JSON. Admin-API (`NikshayAuthClient`) runs it
before every Ni-kshay phase run (ENROLMENT, CONTACTS, NAAT_UPLOAD, XRAY_UPLOAD) and for
`POST /stopTb/nikshay/testLogin`. All other Ni-kshay calls are made by Admin-API itself.

Files:

- `login-json.js`: the worker Admin-API runs
- `lib/auth.js`: the login steps on the portal page
- `lib/config.js`: the portal URLs for `uat` (beta.nikshay.in) and `production`

## Setup (on the machine that runs Admin-API)

```bash
cd nikshay-enroll
npm install
```

Requires Node.js and Chromium (installed via the Playwright postinstall).

Config in the Admin-API properties (defaults shown):

```
nikshay.enroll.dir=nikshay-enroll
nikshay.enroll.node=node
nikshay.login.timeout-ms=180000
nikshay.portal.env=uat
```

`nikshay.enroll.dir` is resolved from the Admin-API working directory.

## Run by hand

```bash
NIKSHAY_USER=... NIKSHAY_PASS=... node login-json.js --env uat
```

The output is a live login: never log it or share it.
168 changes: 168 additions & 0 deletions nikshay-enroll/lib/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Ni-kshay SSO login via Playwright (headless by default).
*/

function randomCaptcha() {
return Math.random().toString(36).slice(2, 8).toUpperCase()
}

async function resolveCaptcha(page) {
const codeEl = page.locator('#capText')
if ((await codeEl.count()) === 0) return randomCaptcha()
for (let i = 0; i < 25; i++) {
const code = ((await codeEl.textContent().catch(() => '')) || '').trim()
if (code.length >= 4) return code
await page.waitForTimeout(300)
}
return randomCaptcha()
}

async function pageCanHashPassword(page, timeoutMs) {
if ((await page.locator('input[name="isEncryptedData"][value="true"]').count()) === 0) return true
return page
.waitForFunction(
() => {
const $ = window.jQuery
if (typeof $ !== 'function' || !window.forge || typeof window.md5 !== 'function') return false
const button = $('.loginButton')[0]
return Boolean(button && $._data?.(button, 'events')?.click?.length)
},
undefined,
{ timeout: timeoutMs }
)
.then(() => true)
.catch(() => false)
}

async function submitWithOwnHashing(page, username, password) {
const hash = await page.evaluate(async (args) => {
const clientId = document.querySelector('input[name="clientId"]')?.value
if (!clientId) return 'ERR:the login page is missing its clientId field'
const base = location.origin
const idHeader = { 'X-SSO-Client-Id': clientId }

const tokenRes = await (
await fetch(`${base}/v1/ssoserver/client`, { credentials: 'include', headers: idHeader })
).json()
const authToken = tokenRes?.data?.authToken
if (!authToken) return 'ERR:the portal did not issue an auth token'

const salt = await (
await fetch(`${base}/v1/user/saltKey?username=${encodeURIComponent(args.user)}`, {
credentials: 'include',
headers: { ...idHeader, 'X-SSO-Auth-Token': authToken }
})
).json()
if (!salt?.success || !salt.data) return 'ERR:the portal did not return a salt for this username'

const tokenRes2 = await (
await fetch(`${base}/v1/ssoserver/client`, { credentials: 'include', headers: idHeader })
).json()
const sessionRes = await (
await fetch(`${base}/v1/ssoserver/session-key`, {
credentials: 'include',
headers: { ...idHeader, 'X-SSO-Auth-Token': tokenRes2?.data?.authToken ?? '' }
})
).json()
if (!sessionRes?.success || !sessionRes.data) return 'ERR:the portal did not return a session key'

let digest = ''
for (let round = 0; round < 2; round++) {
const input = round === 0 ? salt.data + args.pass : sessionRes.data + digest
const bytes = new Uint8Array(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
)
digest = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
return digest
}, { user: username, pass: password })

if (hash.startsWith('ERR:')) return hash.slice(4)

await page
.evaluate((hashed) => {
const pw = document.querySelector('#password')
const external = document.querySelector('#isExternalLogin')
if (external) external.value = 'false'
if (!pw) return
pw.value = hashed
pw.form?.submit()
}, hash)
.catch(() => undefined)
return null
}

/**
* When the user already has a portal session elsewhere (another browser, or another run),
* the portal shows "You are already logged in. Click here to go to Overview Page" instead
* of the dashboard. Follows that link. Returns true when the page was that one and the
* portal opened afterwards.
*/
async function continueIfAlreadyLoggedIn(page) {
const text = (await page.locator('body').innerText({ timeout: 3_000 }).catch(() => '')) || ''
if (!/already logged in/i.test(text)) return false
const link = page.getByText(/click here/i).first()
if ((await link.count()) === 0) return false
await link.click()
await page.waitForLoadState('domcontentloaded').catch(() => undefined)
await page.waitForTimeout(2_000)
const url = new URL(page.url())
// Back on the SSO login page: the click did not open the portal
return !url.hostname.startsWith('sso.') && !/already logged in/i.test(
(await page.locator('body').innerText({ timeout: 3_000 }).catch(() => '')) || '')
}

/** Log in once. Throws on failure. */
export async function login(page, username, password, loginUrl, homeUrlGlob) {
const alerts = []
const onDialog = (d) => {
alerts.push(d.message())
void d.dismiss().catch(() => undefined)
}
page.on('dialog', onDialog)

try {
await page.goto(loginUrl, { waitUntil: 'domcontentloaded' })
const pageHashes = await pageCanHashPassword(page, 8_000)

await page.getByRole('textbox', { name: 'User Name' }).fill(username)
await page.getByRole('textbox', { name: 'Password' }).fill(password)

const captcha = page.getByRole('textbox', { name: 'captcha' })
if ((await captcha.count()) > 0) {
await captcha.first().fill(await resolveCaptcha(page))
}

if (pageHashes) {
await page.getByRole('button', { name: 'Login' }).click()
} else {
const failedStep = await submitWithOwnHashing(page, username, password)
if (failedStep) throw new Error(`Login failed: ${failedStep}.`)
}

try {
await page.waitForURL(homeUrlGlob, { timeout: 20_000 })
} catch {
if (await continueIfAlreadyLoggedIn(page)) return
const msg =
(await page.locator('#setError').first().textContent().catch(() => null)) ||
(await page
.locator('text=/invalid|incorrect|wrong|failed|error/i')
.first()
.textContent()
.catch(() => null))
const detail = [msg?.trim(), alerts.length ? `page alert: ${alerts.join('; ')}` : '']
.filter(Boolean)
.join(' — ')
throw new Error(
detail
? `Login failed: ${detail}`
: 'Login failed: did not reach the dashboard. Check username/password.'
)
}
} finally {
page.off('dialog', onDialog)
}
}
17 changes: 17 additions & 0 deletions nikshay-enroll/lib/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/** UAT (beta) is the default. Pass `production` as last CLI arg to switch. */
export const ENV = {
uat: {
loginUrl:
'https://sso.beta.nikshay.in/v1/sso/login?returnUrl=https://beta.nikshay.in/Home&clientId=29',
homeUrlGlob: '**/beta.nikshay.in/Home',
origin: 'https://beta.nikshay.in'
},
production: {
loginUrl:
'https://sso.nikshay.in/v1/sso/login?returnUrl=https://www.nikshay.in/Home&clientId=29',
homeUrlGlob: '**/www.nikshay.in/Home',
origin: 'https://www.nikshay.in'
}
}

export const DEFAULT_ENV = 'uat'
94 changes: 94 additions & 0 deletions nikshay-enroll/login-json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* CLI for Admin-API (NikshayAuthClient): log in to Ni-kshay in a real browser and
* print the portal's session cookies as ONE JSON object on stdout, so Java can
* make the portal API calls itself.
*
* NIKSHAY_USER=… NIKSHAY_PASS=… node login-json.js --env uat|production
*
* stdout on success: { "origin": "https://beta.nikshay.in", "cookies": [ { "name", "value" }, … ] }
* stdout on failure: { "error": "…", "rejected": true|false } (exit code 1)
* rejected = the portal refused the login (wrong username/password/captcha);
* otherwise the portal couldn't be reached or answered unexpectedly.
*
* The cookies are a live login: stdout is read only by Admin-API and must never be
* logged. Progress goes to stderr and never includes the password or cookies.
*/
import { chromium } from 'playwright'
import { ENV, DEFAULT_ENV } from './lib/config.js'
import { login } from './lib/auth.js'

function arg(name) {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : undefined
}

function fail(message, rejected) {
console.error(`login failed: ${message}`)
process.stdout.write(JSON.stringify({ error: message, rejected }))
process.exitCode = 1
}

const envName = arg('env') || process.env.NIKSHAY_ENV || DEFAULT_ENV
const urls = ENV[envName]
const username = process.env.NIKSHAY_USER
const password = process.env.NIKSHAY_PASS

if (!urls) {
fail(`unknown env "${envName}" (use uat or production)`, false)
} else if (!username || !password) {
fail('NIKSHAY_USER and NIKSHAY_PASS are required', true)
} else {
const headless = process.env.HEADLESS !== '0'
// Prefer system Google Chrome when present; PLAYWRIGHT_CHROME=0 uses Playwright's Chromium.
const useSystemChrome = process.env.PLAYWRIGHT_CHROME !== '0'
let browser
let page
try {
console.error(`login: ${envName} (${urls.origin}) user=${username}`)
browser = await chromium.launch({ headless, ...(useSystemChrome ? { channel: 'chrome' } : {}) })
const context = await browser.newContext()
page = await context.newPage()
page.setDefaultTimeout(30_000)
// The login page waits for jQuery/forge/md5 from public CDNs; loading it has taken
// 2-10s and sometimes more than 30s, so page loads get a longer limit.
page.setDefaultNavigationTimeout(90_000)
await login(page, username, password, urls.loginUrl, urls.homeUrlGlob)
if (!page.url().startsWith(urls.origin)) {
await page.goto(`${urls.origin}/Home`, { waitUntil: 'domcontentloaded' })
}
const cookies = (await context.cookies(urls.origin)).map((c) => ({ name: c.name, value: c.value }))
console.error(`login: ok (${cookies.length} cookies)`)
process.stdout.write(JSON.stringify({ origin: urls.origin, cookies }))
} catch (e) {
let message = (e && e.message ? e.message : String(e)).split('\n')[0]
// Right credentials, but Ni-kshay forces a password change first (first login or
// expired password). Never changed automatically: someone has to do it by hand.
if (page && new URL(page.url()).pathname.endsWith('/resetPassword')) {
message = 'Login failed: Ni-kshay requires a password change for this login. Log in to the portal by hand, ' +
'set a new password, then update it in the device configuration.'
}
// lib/auth.js throws "Login failed: …" when the portal refuses the login
fail(message + (page ? await whereItStopped(page) : ''), message.startsWith('Login failed'))
} finally {
if (browser) await browser.close().catch(() => undefined)
}
}

/**
* Where the browser ended up after a failed login: URL without the query, page
* title and the start of the visible text (e.g. a 403 or error page). Visible
* text never includes what was typed into the password field.
*/
async function whereItStopped(page) {
try {
const url = new URL(page.url())
const title = (await page.title().catch(() => '')).trim()
const text = ((await page.locator('body').innerText({ timeout: 3_000 }).catch(() => '')) || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 300)
return ` [ended at ${url.origin}${url.pathname}; title "${title}"; page text: "${text}"]`
} catch {
return ''
}
}
53 changes: 53 additions & 0 deletions nikshay-enroll/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading