Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-09-07-broker-connect-per-broker-auth

Decisioncanonicalverified 2026-09-07

DECISION.2026-09-07.BROKER-CONNECT-PER-BROKER-AUTH

Broker connect resolves to three auth shapes behind one shared shell

Decision

The connect flow for settings-linked-brokers is built as three auth shapes, not four bespoke flows and not one uniform one:

Shape Brokers What the user does
oauth_redirect Zerodha (Kite), Upstox Redirected to the broker's own login page; returns to /settings/brokers/callback with a single-use code
access_token Dhan Pastes a personal access token generated in Dhan's own console
totp_login Angel One Enters client code + trading PIN + authenticator code

The shape is declared server-side, once, in apps/functions/src/portfolio/broker-registry.ts and returned to the client by getBrokerConnectOptions. The client never guesses which broker does what — it renders a redirect, a paste field, or a three-field form purely from the returned auth_kind.

A broker whose app-level API credentials are absent from this deployment reports available: false with a plain-English line ("Not available yet — upload this broker's holdings statement in Inbox instead") and its row is greyed out. Dhan is the exception that needs no app registration at all, so it is connectable on any deployment including a fresh emulator.

Why

ROADMAP.md Phase 9's own deviation note says it plainly: "each broker's auth flow differs — no single connect UI can be fully uniform underneath a shared shell," and settings-linked-brokers defers the real per-broker shape to "Phase 9 backend-build work". This is that resolution.

Three shapes rather than four: Zerodha and Upstox are both genuine OAuth redirects whose only differences (Kite's redirect_params vs Upstox's state; request_token vs code) are naming, and normalising them at the boundary means one callback page instead of two near-identical ones. Dhan and Angel One are genuinely different from those and from each other, so they stay distinct.

Declaring the shape on the server, rather than hard-coding a per-broker branch in the UI, is what lets the two entry points (settings-linked-brokers and onboarding-connect-broker) share one connect action — 2026-09-01-onboarding-broker-nudge requires them to be the same experience, and a client-side table would be a second place for that to drift.

available: false exists because three of the four brokers need an app registered with them before anyone can connect. Without it, a user on a deployment missing those keys would click Connect and get an error; with it they see the row is not offered yet and what to do instead. It also means the feature can ship one broker at a time.

Impact

  • New apps/functions/src/portfolio/broker-registry.ts, broker-connect.ts (5 callables), broker-sync.ts (the shared sync engine).
  • New apps/functions/src/triggers/scheduled/daily-broker-sync.ts at 05:30 IST — this is what makes settings-linked-brokers' "sync automatically" copy literally true. Accounts already in session_expired are skipped until the user reconnects.
  • New auth modules in packages/brokers — zerodha/auth.ts, upstox/auth.ts, angel-one/auth.ts (+ angel-one/headers.ts shared with its client), and errors.ts' BrokerAuthError so "your broker logged you out" is distinguishable from "the fetch failed". Dhan has no auth.ts — there is nothing to exchange.
  • New website routes /settings/brokers, /settings/brokers/callback, /onboarding/brokers; a 6th Settings rail item; Portfolio entry points on the empty state and the Holdings page.
  • New env: KITE_API_KEY/KITE_API_SECRET, UPSTOX_CLIENT_ID/UPSTOX_CLIENT_SECRET, ANGEL_ONE_API_KEY, BROKER_OAUTH_REDIRECT_URL.
  • Angel One sends fixed device headers. SmartAPI's docs present X-ClientLocalIP / X-ClientPublicIP / X-MACAddress as the caller's real network identity; they are not validated. See "Validated against OpenAlgo" below.
  • Two AY buckets are flagged dirty per sync. deriveAyFromDate(today) is where the snapshot factually sits; getCurrentAy() is the AY the Portfolio page actually renders (it returns the currently-being-filed year, one step behind). Flagging only one would leave the visible review stale or skip the bucket the rows are keyed to. The underlying naming mismatch between those two helpers is pre-existing and deliberately not addressed here.

Validated against OpenAlgo

Every adapter was checked against OpenAlgo (AGPL-3.0, ~/git/trading/openalgo), a production Indian trading platform covering 38 brokers against real accounts. It is a reference for the brokers' API contracts only — no code was copied; endpoint paths, field names and response shapes are facts about third-party APIs, not OpenAlgo's expression.

Endpoints and auth flows matched what was already built. The response shapes did not, and every mismatch below would have failed a real sync:

What we assumed What brokers actually return Fix
average_price / last_price are numbers Zerodha leaves them null for stock it cannot price (freshly transferred shares, suspended scrips); Upstox returns null/0 for shares never bought (IPO allotments, bonuses, transfers) Prices parse as unknown and go through optionalRupeesToPaise. A strict z.number() rejected the whole array, turning one unpriced holding into "your entire portfolio failed to sync"
A missing price means ₹0 A missing price means unknown optionalRupeesToPaise returns undefined for anything not positive-and-finite, so mapToPortfolioPositions falls back to cost basis and suppresses the return. Passing 0 through would have posted a real holding as worth ₹0 at −100%
Empty portfolio → [] Zerodha → null; Upstox → data: null; Angel One → data.holdings: null; Dhan → an error object (DHOLDING_ERROR / DH-1111 / "No holdings available") Each response schema normalises its own empty shape to []. On Dhan this was the worst of the four: an empty demat would have failed the first sync and told the user "Dhan didn't accept those details" for a perfectly valid token
Upstox names the symbol trading_symbol It ships both trading_symbol and tradingsymbol in the same object Prefer the current name, fall back to the alias
Angel One validates the device headers OpenAlgo sends the literal strings "CLIENT_LOCAL_IP" / "MAC_ADDRESS" on both login and authenticated portfolio calls, in production, and Angel One accepts them Fixed constants in angel-one/headers.ts. This deleted the connect_context field, the per-user pseudo-MAC derivation, the capture-and-replay plumbing, and a "reconnect to refresh your device details" failure path — and stops sending a real user's IP to a broker that ignores it
Dhan's only auth is a pasted token Dhan also has a full consent flow (auth.dhan.co generate-consent → browser login → consume-consent), which additionally returns dhanClientId and a token expiry Left on paste deliberately: the consent flow needs a registered Dhan app, which would cost Dhan its status as the one broker connectable with zero deployment config

Also adopted: quantity coerces from strings and rows with no quantity are dropped rather than stored as zero-unit positions.

Not taken from OpenAlgo: its per-broker master-contract symbol database (we key on ISIN, so securityId → symbol resolution is unnecessary) and everything order-placing, which read-only-review forbids.

Not addressed

  • PAN encryption — still the real, open gap 2026-09-01-broker-token-encryption called out. Untouched.
  • Refresh tokens. Neither Kite nor Upstox issues one; Angel One's refresh still needs the user's TOTP, so storing it would buy nothing over a reconnect and would be a second live credential to protect. Daily reconnect is the documented behaviour for two of the four brokers, surfaced as "Session expired · Reconnect", never as an error.
  • Multiple accounts per broker. The linked-account doc id IS the provider slug, so one account per broker per user, matching the one-row-per-provider wireframe.
  • Mutual-fund holdings. Kite's /portfolio/holdings covers demat equity and ETFs only; MF units bought through Coin sit behind a separate /mf/holdings endpoint. For an Indian retail portfolio that is a large omission, but it is a Portfolio-coverage feature rather than part of the connect flow, and OpenAlgo offers no reference for it (it is an order-execution platform, not a portfolio tracker). Uploaded MF CAS statements remain the source for MF holdings.
  • The other 34 brokers OpenAlgo supports. 2026-09-01-broker-api-sync-as-source scopes this to four; extending it is a decision for that ADR, not a build detail.
  • KMS key ring provisioning. A deploy-time ops step. _lib/kms.ts throws at module load in production without BROKER_TOKEN_KMS_KEY_NAME, and because index.ts imports the broker callables that takes down the whole functions deploy — deliberate, per 2026-09-01-broker-token-encryption, and now flagged in apps/functions/.env.chitragupt-d55f6 with the exact gcloud commands.

Status

Active.

Sources

Every project of mine is written down like this.

Read the résumé