Skip to Main Content

Python Login (Selenium)

Headless, unattended OAuth login for scheduled jobs — drives the Shoonya login page with Selenium, captures the redirect code automatically, and exchanges it for an access token.

Overview

This automates the same 3-step OAuth flow described in Manual Login (OAuth), but without a human clicking through it. A headless Chrome session fills in the login form (User ID, Password, TOTP), submits it, and sniffs the network log for the redirect code — then exchanges it for an access token via NorenRestApiPy.

When to use thisScheduled jobs, cron/systemd timers, or CI environments that need a fresh token before market open with nobody present to complete the OAuth redirect by hand. If a person is present, use Manual Login (OAuth) instead — it's simpler and doesn't depend on the login page's DOM.
Fragility warningThis drives the login page's UI, not a documented API — it depends on the current markup of Shoonya's hosted login screen and can break silently if that page changes. See the Notes section below.

Prerequisites

RequirementNotes
Python 3.9+Tested against selenium ≥ 4.x
Chrome + matching chromedriverMust resolve on PATH, or pass webdriver.Chrome(service=...)
selenium, pyotppip install selenium pyotp
NorenRestApiPyUsed only for the final getAccessToken exchange
IP whitelistingSame requirement as manual OAuth — see IP Whitelisting Guide
Environment variableDescription
SHOONYA_CLIENT_IDYour app's client/API key (e.g. AB1234_U)
SHOONYA_USER_IDLogin/user ID routed to (e.g. AB1234)
SHOONYA_PASSWORDAccount password
SHOONYA_TOTP_SECRET32-char base32 TOTP seed used to generate the 6-digit OTP
SHOONYA_API_SECRETApp secret used in the code → token exchange
Never hard-code theseLoad all five values from environment variables or a git-ignored secrets file — never commit them to source.

How it works

Step by step:

  1. Launches headless Chrome with performance logging enabled.
  2. Opens the OAuth login URL and waits for the password field to render.
  3. Fills the first three visible, non-hidden inputs on the page — in order — with User ID, Password, then a freshly generated TOTP code.
  4. Clicks LOGIN, then polls Chrome's performance log for an outgoing request containing a code= query param — the same redirect described in Manual Login (OAuth) Step 2.
  5. If no code appears within 60 seconds, regenerates the TOTP (in case the 30-second window rolled over) and retries once.
  6. Tears down the browser, then exchanges the captured code for an access token via NorenApiPy.getAccessToken(...) — equivalent to the GenAcsTok call in Step 3 of the manual flow.

Full example

python
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import InvalidSessionIdException, WebDriverException
from urllib.parse import urlparse, parse_qs
import pyotp
import time
import json
import os

# ─── CONFIG ───────────────────────────────────────────────────────────
# Load from environment variables (or a git-ignored .env / cred.yml) —
# never hard-code these directly in the script.
CLIENT_ID    = os.environ["SHOONYA_CLIENT_ID"]     # e.g. "AB1234_U"
USER_ID      = os.environ["SHOONYA_USER_ID"]       # e.g. "AB1234"
PASSWORD     = os.environ["SHOONYA_PASSWORD"]
TOTP_SECRET  = os.environ["SHOONYA_TOTP_SECRET"]   # 32-char base32 string
SECRET_CODE  = os.environ["SHOONYA_API_SECRET"]

LOGIN_URL = (
    "https://api.shoonya.com/OAuthlogin/investor-entry-level/login"
    f"?api_key={CLIENT_ID}&route_to={USER_ID}"
)
TOKEN_URL = "https://api.shoonya.com/NorenWClientAPI/GenAcsTok"


def scan_network_for_code(driver):
    try:
        logs = driver.get_log("performance")
        for entry in logs:
            try:
                message = json.loads(entry["message"])["message"]
                if message.get("method") == "Network.requestWillBeSent":
                    url = message.get("params", {}).get("request", {}).get("url", "")
                    if "code=" in url and "shoonya" in url.lower():
                        parsed = urlparse(url)
                        code = parse_qs(parsed.query).get("code", [None])[0]
                        if code:
                            return code
            except Exception:
                continue
    except Exception:
        pass
    return None


def fast_fill(driver, element, value):
    element.click()
    time.sleep(0.1)
    element.clear()
    element.send_keys(value)
    time.sleep(0.1)


# ── Chrome, headless ────────────────────────────────────────────────
options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
options.set_capability("goog:loggingPrefs", {"performance": "ALL"})

driver = webdriver.Chrome(options=options)
wait = WebDriverWait(driver, 30)

auth_code = None

try:
    print("Logging in to Shoonya (background)...")
    driver.get(LOGIN_URL)

    wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "input[type='password']")))
    time.sleep(1)

    all_inputs = driver.find_elements(
        By.CSS_SELECTOR,
        "input:not([type='hidden']):not([type='checkbox']):not([type='radio'])"
    )
    visible_inputs = [inp for inp in all_inputs if inp.is_displayed()]

    fast_fill(driver, visible_inputs[0], USER_ID)
    fast_fill(driver, visible_inputs[1], PASSWORD)

    otp_value = pyotp.TOTP(TOTP_SECRET).now()
    fast_fill(driver, visible_inputs[2], otp_value)

    wait.until(EC.element_to_be_clickable((By.XPATH, "//button[normalize-space()='LOGIN']"))).click()
    print("Credentials submitted. Capturing auth code...")

    start = time.time()
    while True:
        auth_code = scan_network_for_code(driver)
        if auth_code:
            print("Auth code captured.")
            break

        if time.time() - start > 60:
            new_otp = pyotp.TOTP(TOTP_SECRET).now()
            if new_otp != otp_value:
                fast_fill(driver, visible_inputs[2], new_otp)
                wait.until(EC.element_to_be_clickable((By.XPATH, "//button[normalize-space()='LOGIN']"))).click()
                start = time.time()
                otp_value = new_otp
                continue
            print("[TIMEOUT] Could not capture auth code.")
            break

        time.sleep(0.5)

except (InvalidSessionIdException, WebDriverException) as e:
    print(f"[ERROR] Browser issue: {e}")
except Exception as e:
    print(f"[ERROR] {e}")
finally:
    try:
        driver.quit()
    except Exception:
        pass

if not auth_code:
    raise SystemExit("No auth code captured — aborting before token exchange.")

# ── Exchange auth code for an access token ─────────────────────────
from api_helper import NorenApiPy  # noqa: E402

api = NorenApiPy()
result = api.getAccessToken(auth_code, SECRET_CODE, CLIENT_ID, USER_ID)

if result is not None:
    acc_tok, usrid, ref_tok, actid = result
    print(f"Access token retrieved for account: {actid}")
    # Store acc_tok / ref_tok wherever your app reads them from
    # (e.g. write back to the same git-ignored cred file) — avoid
    # printing full tokens to logs in anything but local debugging.
else:
    print("Failed to retrieve access token.")

Best practices

  • Never hard-code CLIENT_ID, PASSWORD, TOTP_SECRET, or API_SECRET — load them from environment variables or a git-ignored secrets file.
  • Don't log or print acc_tok / ref_tok in production; the sample only prints the account ID.
  • Cache the resulting access token (e.g. in a git-ignored cred file) instead of re-running the full Selenium flow on every process start — tokens are typically valid for the trading day.
  • Pin your chromedriver version to your installed Chrome version to avoid silent breakage after browser auto-updates.
  • Wrap this in a retry/alerting wrapper if it runs unattended — headless UI automation is inherently more brittle than a documented API call and can break on any login-page redesign.

Notes

  • This script drives the login page, not a documented API endpoint — it depends on the current DOM structure of the OAuth login screen and will break if that markup changes.
  • Field targeting is positional: it assumes the first three visible, non-hidden <input> elements on the page are, in order, User ID → Password → OTP. If the page adds or reorders fields, this fills the wrong inputs silently rather than erroring — prefer targeting by name/id/placeholder if the login page exposes stable attributes.
  • NorenApiPy is imported after the try/finally block, deliberately after the browser has already quit — the Selenium portion only needs to produce auth_code; the token exchange itself is a plain HTTPS call, same as Step 3 in Manual Login (OAuth).
  • If the TOTP window rolls over mid-submit, the script regenerates the code once and resubmits automatically; it does not retry indefinitely.

See Manual Login (OAuth) for the interactive flow this automates, and Token Renewal for refreshing a token without a full re-login.