(20250104) Breeze authorization will be accepted now.

This commit is contained in:
2025-01-04 15:22:51 +05:30
parent aadcc87e2a
commit 0c2d7816f4
13 changed files with 316 additions and 83 deletions
@@ -61,9 +61,6 @@ from utils_v2.api.async_quart import (
handle_failed_request
)
# GMail-related utils:
from utils_v2.goog.controllers.gmail.gmail_client import SCOPES_GMAIL_MAIL_MANAGEMENT
# Data Models:
from models.core.auth_token import CoreAuthTokenModel
@@ -124,17 +121,19 @@ async def handle_auth_exception():
return await render_template(
"/finstitutions/trading/oauth/oauth_failure_v2.html",
client = g.client_label,
failure_hint = (
f"Something went wrong (E). "
failure_hint = " ".join([
f"Something went wrong (E).",
g.client_response.message,
f"Please use log-id <b>'{g.log_id}'</b> to check with the support team."
)
])
)
# ---------------------------------------------------------------------------------------------------------------------
@trading_oauth_callback_bp.route("/callback/<trading_client>", methods = ["POST", "GET"])
@trading_oauth_callback_bp.route("/callback/<trading_client>", methods = ["GET", "POST"])
@trading_oauth_callback_bp.route("/callback/<trading_client>/<client_user_id>", methods = ["GET", "POST"])
@set_api_version(api_version = "1.0.0")
@read_input(sanitize_headers = False, sanitize_data = False)
@log_request_to_mongo(
@@ -152,6 +151,7 @@ async def handle_auth_exception():
@handle_failed_request(cleanup_coro = handle_auth_exception)
async def trading_oauth_callback(
trading_client: str = None,
client_user_id: str = None,
inbound_headers: dict = None,
inbound_data: dict = None,
inbound_files: dict = None,
@@ -162,6 +162,7 @@ async def trading_oauth_callback(
This endpoint gets triggered by the stockbroker's servers to let you know when a user accepted or rejected an
authorization request.
:param trading_client: The name of the stockbroker that you have received the callback from.
:param client_user_id: How the trading client identifies this user.
:param inbound_headers: auto-extracted by the decorators.
:param inbound_data: auto-extracted by the decorators.
:param inbound_files: auto-extracted by the decorators.
@@ -174,20 +175,31 @@ async def trading_oauth_callback(
# Store needed values in 'g':
g.log_id = kwargs.get("log_id")
g.client_label = "Zerodha (Kite)"
g.client_label_map = {
"zerodhaKite": "Zerodha (Kite)",
"iciciBreeze": "ICICI (Breeze)"
}
# Start by assuming failure:
success = None
g.client_response = None
# ┏┓ ┓┏┓
# ┣ ┏┓┏┓ ┏┛┏┓┏┓┏┓┏┫┣┓┏┓ ┃┫ ┓╋┏┓
# ┻ ┗┛┛ ┗┛┗ ┛ ┗┛┗┻┛┗┗┻ ┛┗┛┗┗
# ┳┓┏┓ ┓ •
# ┣┏┓┏┓┃┏┏┓┏┓ ┗┓┏┓┃┏┓┏╋┓┏┓┏┓
# ┻┛┛ ┗┛┛┗ ┛ ┗┛┗ ┗┗ ┗┗┗┗┛┛
if trading_client == "zerodha":
success = await current_app.zerodha_kite_controller.handle_authorization_callback(
g.client_label = g.client_label_map.get(trading_client, trading_client)
match trading_client:
case "zerodhaKite": client_controller = current_app.zerodha_kite_controller
case "iciciBreeze": client_controller = current_app.icici_breeze_controller
case _: client_controller = None
if client_controller is not None:
g.client_response = await client_controller.handle_authorization_callback(
sql_conn = current_app.sql_writer,
mongo_data_conn = current_app.data_mongo,
inbound_data = inbound_data
inbound_data = inbound_data,
client_user_id = client_user_id
)
# ┳┓
@@ -195,30 +207,35 @@ async def trading_oauth_callback(
# ┛┗┗ ┛┣┛┗┛┛┗┛┗
# ┛
# No valid client:
if success is None: return await render_template(
"/finstitutions/trading/oauth/oauth_failure_v2.html",
client = g.client_label,
failure_hint = (
f"Invalid client '{g.client_label}' selected. "
f"Please use log-id '{g.log_id}' to check with the support team."
# Prepare a set of warning messages:
callback_url_upgrade_warning = (
"<br><br><b>WARNING: You are using the old callback system which could be discontinued at any time. "
"Please update the callback URL on your broker's portal to include your user id in it.</b>"
)
# No valid client:
if client_controller is None: return await render_template(
"/finstitutions/trading/oauth/oauth_failure_v2.html",
failure_hint = " ".join([
f"Invalid client '{g.client_label}' selected.",
f"Please use log-id <b>'{g.log_id}'</b> to check with the support team."
])
)
# Successful auth:
if success: return await render_template(
if g.client_response.success: return await render_template(
"/finstitutions/trading/oauth/oauth_success_v2.html",
client = g.client_label
client = g.client_label,
extra_message = callback_url_upgrade_warning if client_user_id is None else ""
)
# Failed auth:
if success is None: return await render_template(
if g.client_response.success is False: return await render_template(
"/finstitutions/trading/oauth/oauth_failure_v2.html",
client = g.client_label,
failure_hint = (
f"Something went wrong (NE). "
f"Please use log-id '{g.log_id}' to check with the support team."
)
failure_hint = " ".join([
g.client_response.message,
f"Please use log-id <b>'{g.log_id}'</b> to check with the support team."
])
)
@@ -171,6 +171,9 @@ async def request_oauth_authorization_url(
# ┻ ┗┛┛ ┣┛┗┻┣┛┗ ┛ ┻ ┛ ┗┻┗┻┗┛┗┗┫
# ┛ ┛
# This is an internal paper-trading account.
# It won't need daily logins for usage.
if inbound_data.client == "paperTrading":
# Immediately save the details against that token id:
@@ -187,7 +190,7 @@ async def request_oauth_authorization_url(
},
auth = inbound_data.auth.model_dump(),
status = "active",
syncFreq = 1500
syncFreq = None
),
token_notes = {
"username": inbound_data.auth.username
@@ -205,7 +208,7 @@ async def request_oauth_authorization_url(
# ┻ ┗┛┛ ┗┛┗ ┛ ┗┛┗┻┛┗┗┻ ┛┗┛┗┗┗
# PLANNED FLOW FOR ZERODHA-KITE:
# Step 01.: (One time) The user will go to the integrations page and add his API Key and SPI Secret there. We store
# Step 01.: (One time) The user will go to the integrations page and add his API Key and API Secret there. We store
# these values without verification.
# Step 02.: (Daily) The user will go to the investments tab and click on his Zerodha account, which will give him
# a URL that will take him to Zerodha's official site to log in. When he logs in, Zerodha will hit our
@@ -226,17 +229,19 @@ async def request_oauth_authorization_url(
authType = "oauth",
user = kwargs["session_info"],
clientUserId = {
"userId": inbound_data.auth.userId,
"apiKey": inbound_data.auth.apiKey
},
auth = inbound_data.auth.model_dump(),
status = "active",
syncFreq = 1500
syncFreq = None
),
token_notes = {
"userId": inbound_data.auth.userId,
"apiKey": inbound_data.auth.apiKey,
"authUrl": auth_url
},
display_name = None,
display_name = inbound_data.auth.userId,
display_picture = None,
session_token = inbound_headers["X-Session-Token"]
)
@@ -248,6 +253,13 @@ async def request_oauth_authorization_url(
# ┣ ┏┓┏┓ ┃┃ ┃┃ ┃ ┣┫┏┓┏┓┏┓┓┏┓
# ┻ ┗┛┛ ┻┗┛┻┗┛┻ ┻┛┛ ┗ ┗ ┗┗
# PLANNED FLOW FOR ICICI-BREEZE:
# Step 01.: (One time) The user will go to the integrations page and add his Client User ID, API Key, and API Secret
# there. We store these values without verification.
# Step 02.: (Daily) The user will go to the investments tab and click on his Breeze account, which will give him a
# URL that will take him to ICICI's official site to log in. When he logs in, ICICI will hit our callback
# URL and give us the authentication details.
if inbound_data.client == "iciciBreeze":
# Prepare the inputs:
@@ -263,17 +275,19 @@ async def request_oauth_authorization_url(
authType = "oauth",
user = kwargs["session_info"],
clientUserId = {
"userId": inbound_data.auth.userId,
"apiKey": inbound_data.auth.apiKey
},
auth = inbound_data.auth.model_dump(),
status = "active",
syncFreq = 1500
syncFreq = None
),
token_notes = {
"userId": inbound_data.auth.userId,
"apiKey": inbound_data.auth.apiKey,
"authUrl": auth_url
},
display_name = None,
display_name = inbound_data.auth.userId,
display_picture = None,
session_token = inbound_headers["X-Session-Token"]
)
@@ -46,6 +46,7 @@ from controllers_v2.finstitutions.trading.base import TradingController
# Models:
from models.core.auth_token import CoreAuthTokenModel
from models.api.finstitutions.trading.symbols.list import TradingSymbolListRequestData, TradingSymbolListBrokerResponse
from models.finstitutions.trading.oauth import TradingOAuthCallbackResponse
# To work with datatypes:
from typing import List, Any
@@ -150,8 +151,9 @@ class AllTradingController(TradingController):
self,
sql_conn: AsyncMySQL,
mongo_data_conn: AsyncMongo,
inbound_data: dict
) -> bool:
inbound_data: dict,
client_user_id: str
) -> TradingOAuthCallbackResponse:
"""
When the end user interacts with their broker's APIs, the broker's servers would usually issue a callback.
We've seen this in the case of Zerodha Kite and ICICI Breeze. Use this method to handle the callback loop to
@@ -159,7 +161,8 @@ class AllTradingController(TradingController):
:param sql_conn: The database connection to use to perform this activity.
:param mongo_data_conn: The database connection to use to perform this activity.
:param inbound_data: The data that came in from the broker. This could be in the JSON body, query params, etc.
:return: True if the callback loop was completed successfully, else False.
:param client_user_id: How the trading client identifies this user.
:return: A structured response to capture the process of callback handling.
"""
raise NotImplementedError
+6 -3
View File
@@ -46,6 +46,7 @@ from controllers_v2.core.auth_token import CoreAuthTokenController
# Models:
from models.core.auth_token import CoreAuthTokenModel
from models.api.finstitutions.trading.symbols.list import TradingSymbolListRequestData, TradingSymbolListBrokerResponse
from models.finstitutions.trading.oauth import TradingOAuthCallbackResponse
# To work with datatypes:
from typing import List, Any
@@ -170,8 +171,9 @@ class TradingController(CoreAuthTokenController, ABC):
self,
sql_conn: AsyncMySQL,
mongo_data_conn: AsyncMongo,
inbound_data: dict
) -> bool:
inbound_data: dict,
client_user_id: str
) -> TradingOAuthCallbackResponse:
"""
When the end user interacts with their broker's APIs, the broker's servers would usually issue a callback.
@@ -180,7 +182,8 @@ class TradingController(CoreAuthTokenController, ABC):
:param sql_conn: The database connection to use to perform this activity.
:param mongo_data_conn: The database connection to use to perform this activity.
:param inbound_data: The data that came in from the broker. This could be in the JSON body, query params, etc.
:return: True if the callback loop was completed successfully, else False.
:param client_user_id: How the trading client identifies this user.
:return: A structured response to capture the process of callback handling.
"""
pass
@@ -51,6 +51,7 @@ from models.api.finstitutions.trading.symbols.list import (
TradingSymbolListBrokerResponse,
TradingSymbol
)
from models.finstitutions.trading.oauth import TradingOAuthCallbackResponse
# To work with MongoDB:
from bson.objectid import ObjectId
@@ -62,8 +63,8 @@ from typing import List, Any
import httpx
import urllib
# To work with Zerodha's Kite platform:
from kiteconnect import KiteConnect
# To work with ICICI Breeze's platform:
from breeze_connect import BreezeConnect
# To handle exceptions:
from pydantic import ValidationError
@@ -179,8 +180,9 @@ class ICICIBreezeTradingController(TradingController):
self,
sql_conn: AsyncMySQL,
mongo_data_conn: AsyncMongo,
inbound_data: dict
) -> bool:
inbound_data: dict,
client_user_id: str
) -> TradingOAuthCallbackResponse:
"""
To capture the callback from ICICI Breeze's authorization loop. This happens when the user successfully logs in
@@ -188,7 +190,8 @@ class ICICIBreezeTradingController(TradingController):
:param sql_conn: The database connection to use to perform this activity.
:param mongo_data_conn: The database connection to use to perform this activity.
:param inbound_data: The data that came in from the broker. This could be in the JSON body, query params, etc.
:return: True if the callback loop was completed successfully, else False..
:param client_user_id: How the trading client identifies this user.
:return: A structured response to capture the process of callback handling.
"""
raise NotImplementedError
@@ -52,6 +52,7 @@ from models.api.finstitutions.trading.symbols.list import (
TradingSymbolListBrokerResponse,
TradingSymbol
)
from models.finstitutions.trading.oauth import TradingOAuthCallbackResponse
# To work with MongoDB:
from bson.objectid import ObjectId
@@ -179,15 +180,17 @@ class PaperTradingController(TradingController):
self,
sql_conn: AsyncMySQL,
mongo_data_conn: AsyncMongo,
inbound_data: dict
) -> bool:
inbound_data: dict,
client_user_id: str
) -> TradingOAuthCallbackResponse:
"""
Not needed for paper trading.
:param sql_conn: The database connection to use to perform this activity.
:param mongo_data_conn: The database connection to use to perform this activity.
:param inbound_data: The data that came in from the broker. This could be in the JSON body, query params, etc.
:return: True if the callback loop was completed successfully, else False..
:param client_user_id: How the trading client identifies this user.
:return: A structured response to capture the process of callback handling.
"""
raise NotImplementedError
@@ -52,6 +52,7 @@ from models.api.finstitutions.trading.symbols.list import (
TradingSymbolListBrokerResponse,
TradingSymbol
)
from models.finstitutions.trading.oauth import TradingOAuthCallbackResponse
# To work with MongoDB:
from bson.objectid import ObjectId
@@ -179,50 +180,79 @@ class ZerodhaKiteTradingController(TradingController):
self,
sql_conn: AsyncMySQL,
mongo_data_conn: AsyncMongo,
inbound_data: dict
) -> bool:
inbound_data: dict,
client_user_id: str
) -> TradingOAuthCallbackResponse:
"""
When the end user interacts with Zerodha's APIs, Zerodha's servers issue a callback like this:
http://127.0.0.1:5999/auth/callback?action=login&type=login&status=success&request_token=the-request-token
We must use the request token to get the access token. The access token is the thing that we must hold onto for
executing actual actions like subscribing to live market feed, placing trades, etc.
NOTE: Please ensure that you set the 'Redirect URL' such that is passes back Kite's 'api_key' back through the
callback URL. This can be one by setting the value manually as a query param on the app's configuration
page. E.g.: http://127.0.0.1:5999/auth/callback?api_key=user_api_key
NOTE: Please ensure that you set the 'Redirect URL' in the format as shown below:
01. http://127.0.0.1:5106/converse/finstitutions/trading/oauth/callback/zerodhaKite/<client_user_id>
02. https://api.thecaoffice.com/converse/finstitutions/trading/oauth/callback/zerodhaKite/<client_user_id>
BACKWARD COMPATIBILITY:
Earlier, we used to set the same API key in the callback URL as a query param like shown below:
E.g.: http://127.0.0.1:5106/converse/finstitutions/trading/oauth/callback/zerodhaKite?api_key=user_api_key
:param sql_conn: The database connection to use to perform this activity.
:param mongo_data_conn: The database connection to use to perform this activity.
:param inbound_data: The data that came in from the broker. This could be in the JSON body, query params, etc.
:return: True if the callback loop was completed successfully, else False..
:param client_user_id: How the trading client identifies this user.
:return: A structured response to capture the process of callback handling.
"""
# Start by assuming failure:
success = False
response = TradingOAuthCallbackResponse()
zerodha_auth_token = None
# Check if either the new system or the old system is being followed.
# At least one is needed:
api_key = inbound_data.get("api_key")
if not api_key and not client_user_id:
response.message = "Your callback URL hasn't been configured properly."
return response
# Get the token from the database:
old_condition = mongo_data_conn.dict_to_dot_notation({"auth": {"apiKey": api_key}})
condition = mongo_data_conn.dict_to_dot_notation({"auth": {"userId": client_user_id}})
auth_token = await self.get_token_from_filter(
mongo_data_conn = mongo_data_conn,
filter_json = mongo_data_conn.dict_to_dot_notation({
"auth": {
"apiKey": inbound_data.get(
"api_key",
"Hint: Put the user's app's key in the query params of the 'Redirect URL'"
)
}
})
filter_json = {"$or": [old_condition, condition]}
)
# If not such auth token exists:
if not auth_token: return success
if not auth_token:
response.message = (
f"No such integration found in our system. "
"Please add this integration first and then try again."
)
return response
# Get the final access tokens set from Zerodha Kite:
try:
kite = KiteConnect(api_key = auth_token.auth["apiKey"])
session_data = kite.generate_session(
request_token = inbound_data["request_token"],
api_secret = auth_token.auth["apiSecret"]
)
zerodha_auth_token = ZerodhaKiteAuthTokens(**session_data)
except Exception as exception:
response.exception = exception
response.message = str(exception)
return response
# Ensure that the client user id of the incoming callback and the one given in Zerodha's session data match:
if (
client_user_id is not None and # ................ For backward compatibility.
zerodha_auth_token.userId != client_user_id # ... New mechanism that verifies account match.
):
response.message = (
f"We were expecting authorization for the account '{client_user_id}', "
f"but Zerodha says the authorization was granted for the account '{zerodha_auth_token.userId}'. "
"This could be because of a misconfigured callback URL."
)
return response
# Prepare the inputs to save to the database:
auth_url = await self.get_authorization_url(api_key = auth_token.auth["apiKey"])
@@ -242,8 +272,15 @@ class ZerodhaKiteTradingController(TradingController):
display_picture = zerodha_auth_token.displayPictureUrl
)
# If saving the token fails:
if not success:
response.message = "Something went wrong towards the end of the authorization cycle."
return response
# Done here:
return success
response.success = True
response.message = "Authorization cycle successfully completed."
return response
# ┏┳┓ ┓• ┏┓ ┓ ┓
# ┃ ┏┓┏┓┏┫┓┏┓┏┓ ┗┓┓┏┏┳┓┣┓┏┓┃┏
+28 -8
View File
@@ -101,18 +101,24 @@ class PaperTradingAuth(BaseModel):
class ZerodhaKiteAuth(BaseModel):
userId: str = Field(
description = "How Zerodha's Kite platform identifies this user.",
frozen = True,
alias = "clientId"
)
apiKey: str = Field(
description = (
"the api key of your kite connect app; "
"this remains constant throughout the life of the app"
"The API key of your Kite app. "
"This remains constant throughout the life of the app."
),
frozen = True
)
apiSecret: str = Field(
description = (
"the api secret of your kite connect app; "
"this can change if you think the security of your app has been compromised"
"The API secret of your Kite app. "
"This can be changed if you think the security of your app has been compromised."
),
frozen = True
)
@@ -124,6 +130,7 @@ class ZerodhaKiteAuth(BaseModel):
class Config:
extra = "forbid"
populate_by_name = True
# ---------------------------------------------------------------------------------------------------------------------
@@ -131,13 +138,25 @@ class ZerodhaKiteAuth(BaseModel):
class ICICIBreezeAuth(BaseModel):
userId: str = Field(
description = "How ICICI's Breeze platform identifies this user.",
frozen = True,
alias = "clientId"
)
apiKey: str = Field(
description = "??",
description = (
"The API key of your Breeze app. "
"This remains constant throughout the life of the app."
),
frozen = True
)
apiSecret: str = Field(
description = "??",
description = (
"The API secret of your Breeze app. "
"This can be changed if you think the security of your app has been compromised."
),
frozen = True
)
@@ -148,6 +167,7 @@ class ICICIBreezeAuth(BaseModel):
class Config:
extra = "forbid"
populate_by_name = True
# ---------------------------------------------------------------------------------------------------------------------
@@ -179,8 +199,8 @@ class TradingAuthRequestHeaders(BaseModel):
class TradingAuthRequestData(BaseModel):
client: Literal["zerodhaKite", "iciciBreeze", "paperTrading"] = Field(alias = "client")
auth: Union[ZerodhaKiteAuth, ICICIBreezeAuth, PaperTradingAuth]
client: Literal["paperTrading", "zerodhaKite", "iciciBreeze"] = Field(alias = "client")
auth: Union[PaperTradingAuth, ZerodhaKiteAuth, ICICIBreezeAuth]
# ┏┓ ┏•
# ┃ ┏┓┏┓╋┓┏┓
+127
View File
@@ -0,0 +1,127 @@
"""
AUTHOR:
Khushal P Soonderji
DATE:
Saturday, 4th Jan., 2025.
OBJECTIVE:
To provide a structure to represent OAuth callback responses.
REFERENCES:
N/A
DOWNLOADS:
N/A
"""
# *****************************************************************************************************************
# ***** ****
# *** IMPORT ***
# ***** ****
# *****************************************************************************************************************
# To make sibling directories accessible for imports:
import sys
sys.path.append(".")
sys.path.append("..")
# For making data behaviour_models:
from pydantic import BaseModel, Field, field_validator, PastDatetime, model_validator, AwareDatetime
from typing import Optional, Literal, Union, List, Any
# My utils:
from utils_v2.string import regex
from utils_v2.date_time import date_time
# To work with date and time:
import datetime
# *****************************************************************************************************************
# ***** ****
# *** MACROS / ONE-TIME INIT ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** VARIABLES ***
# ***** ****
# *****************************************************************************************************************
# --- Nothing Yet
# *****************************************************************************************************************
# ***** ****
# *** FUNCTIONS ***
# ***** ****
# *****************************************************************************************************************
class TradingOAuthCallbackResponse(BaseModel):
success: bool = Field(
description = "To indicate whether or not, the action was a success",
frozen = False,
default = False
)
message: str = Field(
description = "To explain what happened in the process of handling the OAuth callback.",
frozen = False,
default = "ERR: Message not captured."
)
exception: Any = Field(
description = "To pass on any exception that occurred in the process.",
frozen = False,
default = None
)
# ┏┓ ┏•
# ┃ ┏┓┏┓╋┓┏┓
# ┗┛┗┛┛┗┛┗┗┫
# ┛
class Config:
extra = "forbid"
# ┏┓ ┏┓
# ┃ ┓┏┏╋┏┓┏┳┓ ┣ ┓┏┏┓┏┏
# ┗┛┗┻┛┗┗┛┛┗┗ ┻ ┗┻┛┗┗┛
pass
# ┓┏ ┓• ┓ •
# ┃┃┏┓┃┓┏┫┏┓╋┓┏┓┏┓
# ┗┛┗┻┗┗┗┻┗┻┗┗┗┛┛┗
pass
# *****************************************************************************************************************
# ***** ****
# *** MAIN PROGRAM ***
# ***** ****
# *****************************************************************************************************************
if __name__ == "__main__":
pass
View File
View File
@@ -101,9 +101,11 @@
<div class="message-container">
<div class="failure-circle"></div> <!-- Red circle with failure icon -->
<h1><b>Authorization Failed</b></h1>
<p>Something went wrong in getting authorization from your <b>{{ client }}</b> account.
<p>
Something went wrong in getting authorization from your <b>{{ client }}</b> account.
<br><br><b>Hint:</b> {{ failure_hint|safe }}<br><br>
Please feel free to try the same steps again. You can close this tab at any time.</p>
Please feel free to try the same steps again. You can close this tab at any time.
</p>
<!-- Close button -->
<button class="btn btn-secondary" onclick="window.close();">Close</button>
@@ -101,7 +101,11 @@
<div class="message-container">
<div class="success-circle"></div> <!-- Red circle with failure icon -->
<h1><b>Authorization Successful</b></h1>
<p>We have received authorization from your <b>{{ client }}</b> account. You can close this tab at any time.</p>
<p>
We have received authorization from your <b>{{ client }}</b> account.
{{ extra_message|safe }}
<br><br>You can close this tab at any time.
</p>
<!-- Close button -->
<button class="btn btn-secondary" onclick="window.close();">Close</button>