Skip to content

Commit 2aaa55d

Browse files
authored
feat: USA OTP (#991)
1 parent dd56857 commit 2aaa55d

4 files changed

Lines changed: 51 additions & 163 deletions

File tree

hyundai_kia_connect_api/ApiImpl.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
GEO_LOCATION_PROVIDERS,
2727
OPENSTREETMAP,
2828
GOOGLE,
29+
OTP_NOTIFY_TYPE,
2930
)
3031

3132
_LOGGER = logging.getLogger(__name__)
@@ -56,6 +57,7 @@ class WindowRequestOptions:
5657
@dataclass
5758
class OTPRequest:
5859
request_id: str | None
60+
otp_key: str | None
5961
has_email: bool | None
6062
has_sms: bool | None
6163
email: str | None
@@ -100,13 +102,18 @@ def login(
100102
"""Login into cloud endpoints and return Token or OTP Details if OTP is triggered"""
101103
pass
102104

103-
def send_otp(
104-
self, otp_request: OTPRequest, otp_destination: str, otp_via: str
105-
) -> None:
105+
def send_otp(self, otp_request: OTPRequest, notify_type: OTP_NOTIFY_TYPE) -> None:
106106
"""Sends OTP to the user via selected destination and via"""
107107
pass
108108

109-
def verify_otp(self, otp_request: OTPRequest, otp_code: str) -> Token:
109+
def verify_otp_and_complete_login(
110+
self,
111+
username: str,
112+
password: str,
113+
otp_code: str,
114+
otp_request: OTPRequest,
115+
pin: str | None = None,
116+
) -> Token:
110117
"""Confirms OTP code sent to the user"""
111118
pass
112119

hyundai_kia_connect_api/KiaUvoApiUSA.py

Lines changed: 24 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from requests.adapters import HTTPAdapter
1616
from urllib3.util.ssl_ import create_urllib3_context
1717

18-
from .ApiImpl import ApiImpl, ClimateRequestOptions
18+
from .ApiImpl import ApiImpl, ClimateRequestOptions, OTPRequest
1919
from .Token import Token
2020
from .Vehicle import Vehicle
2121
from .const import (
@@ -25,9 +25,9 @@
2525
ORDER_STATUS,
2626
TEMPERATURE_UNITS,
2727
VEHICLE_LOCK_ACTION,
28+
OTP_NOTIFY_TYPE,
2829
)
2930
from .utils import get_child_value, parse_datetime
30-
from .exceptions import AuthenticationError
3131

3232

3333
_LOGGER = logging.getLogger(__name__)
@@ -240,92 +240,22 @@ def _complete_login_with_otp(
240240
)
241241
return final_sid
242242

243-
def start_login(
244-
self,
245-
username: str,
246-
password: str,
247-
token: Token | None = None,
248-
) -> tuple[Token | None, dict | None]:
249-
"""Start login and return either a Token or an OTP context.
250-
251-
Parameters
252-
----------
253-
username : str
254-
User email address
255-
password : str
256-
User password
257-
token : Token | None
258-
Existing token with stored rmtoken for reuse
259-
260-
Returns
261-
-------
262-
tuple[Token | None, dict | None]
263-
(Token, None) if login succeeded without OTP, otherwise (None, ctx)
264-
where ctx contains 'otpKey', 'xid', 'email', 'phone', 'hasEmail', 'hasPhone'.
265-
"""
266-
url = self.API_URL + "prof/authUser"
267-
data = {
268-
"deviceKey": self.device_id,
269-
"deviceType": 2,
270-
"userCredential": {"userId": username, "password": password},
271-
}
272-
headers = self.api_headers()
273-
if token and getattr(token, "device_id", None):
274-
self.device_id = token.device_id
275-
276-
if token and token.refresh_token:
277-
_LOGGER.debug(f"{DOMAIN} - Attempting start_login with stored rmtoken")
278-
headers["rmtoken"] = token.refresh_token
279-
response = self.session.post(url, json=data, headers=headers)
280-
_LOGGER.debug(f"{DOMAIN} - Start Sign In Response {response.text}")
281-
response_json = response.json()
282-
session_id = response.headers.get("sid")
283-
if session_id:
284-
_LOGGER.debug(f"got session id {session_id}")
285-
valid_until = dt.datetime.now(dt.timezone.utc) + LOGIN_TOKEN_LIFETIME
286-
existing_rmtoken = token.refresh_token if token else None
287-
return (
288-
Token(
289-
username=username,
290-
password=password,
291-
access_token=session_id,
292-
refresh_token=existing_rmtoken,
293-
valid_until=valid_until,
294-
device_id=self.device_id,
295-
),
296-
None,
297-
)
298-
if "payload" in response_json and "otpKey" in response_json["payload"]:
299-
payload = response_json["payload"]
300-
xid = response.headers.get("xid", "")
301-
ctx = {
302-
"otpKey": payload["otpKey"],
303-
"xid": xid,
304-
"email": payload.get("email"),
305-
"phone": payload.get("phone"),
306-
"hasEmail": bool(payload.get("hasEmail")),
307-
"hasPhone": bool(payload.get("hasPhone")),
308-
"rmTokenExpired": bool(payload.get("rmTokenExpired")),
309-
}
310-
return None, ctx
311-
raise Exception(
312-
f"{DOMAIN} - No session id returned in start_login. Response: {response.text}"
313-
)
314-
315-
def send_otp(self, otp_key: str, notify_type: str, xid: str) -> dict:
243+
def send_otp(self, otp_request: OTPRequest, notify_type: OTP_NOTIFY_TYPE) -> dict:
316244
"""Public helper to send OTP to the selected destination."""
317-
return self._send_otp(otp_key, notify_type, xid)
245+
return self._send_otp(otp_request.otp_key, notify_type, otp_request.request_id)
318246

319247
def verify_otp_and_complete_login(
320248
self,
321249
username: str,
322250
password: str,
323-
otp_key: str,
324-
xid: str,
325251
otp_code: str,
252+
otp_request: OTPRequest,
253+
pin: str | None,
326254
) -> Token:
327255
"""Verify OTP and complete the login producing a Token."""
328-
sid, rmtoken = self._verify_otp(otp_key, otp_code, xid)
256+
sid, rmtoken = self._verify_otp(
257+
otp_request.otp_key, otp_code, otp_request.request_id
258+
)
329259
final_sid = self._complete_login_with_otp(username, password, sid, rmtoken)
330260
_LOGGER.debug(f"got final session id {final_sid}")
331261
_LOGGER.info(f"{DOMAIN} - Storing rmtoken for future logins")
@@ -337,14 +267,14 @@ def verify_otp_and_complete_login(
337267
refresh_token=rmtoken,
338268
valid_until=valid_until,
339269
device_id=self.device_id,
270+
pin=pin,
340271
)
341272

342273
def login(
343274
self,
344275
username: str,
345276
password: str,
346277
token: Token = None,
347-
otp_handler: ty.Callable[[dict], dict] | None = None,
348278
pin: str | None = None,
349279
) -> Token:
350280
"""Login into cloud endpoints and return Token
@@ -359,7 +289,7 @@ def login(
359289
Existing token with stored rmtoken for reuse
360290
otp_handler : Callable[[dict], dict], optional
361291
Non-interactive OTP handler. Called twice:
362-
- stage='choose_destination' -> return {'notify_type': 'EMAIL'|'PHONE'}
292+
- stage='choose_destination' -> return {'notify_type': 'EMAIL'|'SMS'}
363293
- stage='input_code' -> return {'otp_code': '<code>'}
364294
pin : str, optional
365295
@@ -377,19 +307,17 @@ def login(
377307
}
378308
if token and getattr(token, "device_id", None):
379309
self.device_id = token.device_id
380-
if otp_handler is not None:
381-
self._otp_handler = otp_handler
382310
headers = self.api_headers()
383311
if token and token.refresh_token:
384312
data["deviceKey"] = self.device_id
385-
_LOGGER.debug(f"{DOMAIN} - Attempting login with stored rmtoken")
313+
_LOGGER.debug(f"{DOMAIN} - Attempting login with stored Refresh Token")
386314
headers["rmtoken"] = token.refresh_token
387315
response = self.session.post(url, json=data, headers=headers)
388316
_LOGGER.debug(f"{DOMAIN} - Sign In Response {response.text}")
389317
response_json = response.json()
390318
session_id = response.headers.get("sid")
391319
if session_id:
392-
_LOGGER.debug(f"got session id {session_id}")
320+
_LOGGER.debug(f"Got session id {session_id}")
393321
valid_until = dt.datetime.now(dt.timezone.utc) + LOGIN_TOKEN_LIFETIME
394322
existing_rmtoken = token.refresh_token if token else None
395323
return Token(
@@ -404,81 +332,22 @@ def login(
404332
payload = response_json["payload"]
405333
if payload.get("rmTokenExpired"):
406334
_LOGGER.info(f"{DOMAIN} - Stored rmtoken has expired, need new OTP")
407-
otp_key = payload["otpKey"]
408-
xid = response.headers.get("xid", "")
409-
_LOGGER.info(f"{DOMAIN} - OTP required for login")
410-
_LOGGER.info(f"{DOMAIN} - Email: {payload.get('email', 'N/A')}")
411-
_LOGGER.info(f"{DOMAIN} - Phone: {payload.get('phone', 'N/A')}")
412-
notify_type = "EMAIL"
413-
handler = otp_handler or getattr(self, "_otp_handler", None)
414-
if handler:
415-
try:
416-
ctx_choice = {
417-
"stage": "choose_destination",
418-
"hasEmail": bool(payload.get("hasEmail")),
419-
"hasPhone": bool(payload.get("hasPhone")),
420-
"email": payload.get("email"),
421-
"phone": payload.get("phone"),
422-
}
423-
res = handler(ctx_choice) or {}
424-
nt = str(res.get("notify_type", notify_type)).upper()
425-
if nt in ("EMAIL", "PHONE"):
426-
notify_type = nt
427-
except Exception:
428-
_LOGGER.debug(
429-
f"{DOMAIN} - otp_handler choose_destination failed; using default"
430-
)
431-
else:
432-
if payload.get("hasEmail") and payload.get("hasPhone"):
433-
print("\nOTP Authentication Required")
434-
print(f"Email: {payload.get('email', 'N/A')}")
435-
print(f"Phone: {payload.get('phone', 'N/A')}")
436-
choice = (
437-
input("Send OTP to (E)mail or (P)hone? [E/P]: ").strip().upper()
438-
)
439-
if choice == "P":
440-
notify_type = "PHONE"
441-
elif payload.get("hasPhone"):
442-
notify_type = "PHONE"
443-
self._send_otp(otp_key, notify_type, xid)
444-
if not handler:
445-
print(f"\nOTP sent to {notify_type.lower()}")
446-
otp_code = None
447-
if handler:
448-
try:
449-
ctx_code = {
450-
"stage": "input_code",
451-
"notify_type": notify_type,
452-
"otpKey": otp_key,
453-
"xid": xid,
454-
}
455-
res2 = handler(ctx_code) or {}
456-
otp_code = str(res2.get("otp_code", "")).strip()
457-
except Exception:
458-
_LOGGER.debug(f"{DOMAIN} - otp_handler input_code failed")
459-
if not otp_code:
460-
if handler is None:
461-
otp_code = input("Enter OTP code: ").strip()
462-
else:
463-
raise AuthenticationError(f"{DOMAIN} - OTP code required")
464-
sid, rmtoken = self._verify_otp(otp_key, otp_code, xid)
465-
final_sid = self._complete_login_with_otp(username, password, sid, rmtoken)
466-
_LOGGER.debug(f"got final session id {final_sid}")
467-
_LOGGER.info(f"{DOMAIN} - Storing rmtoken for future logins")
468-
valid_until = dt.datetime.now(dt.timezone.utc) + LOGIN_TOKEN_LIFETIME
469-
return Token(
470-
username=username,
471-
password=password,
472-
access_token=final_sid,
473-
refresh_token=rmtoken,
474-
valid_until=valid_until,
475-
device_id=self.device_id,
476-
pin=pin,
335+
return OTPRequest(
336+
otp_key=payload["otpKey"],
337+
request_id=response.headers.get("xid", ""),
338+
email=payload.get("email"),
339+
sms=payload.get("phone"),
340+
has_email=bool(payload.get("hasEmail")),
341+
has_sms=bool(payload.get("hasPhone")),
477342
)
478343
raise Exception(
479344
f"{DOMAIN} - No session id returned in login. Response: {response.text} headers {response.headers} cookies {response.cookies}"
480345
)
481346

347+
def refresh_access_token(self, token: Token) -> Token | OTPRequest:
348+
"""Refresh the token using the refresh token"""
349+
return self.login(token.username, token.password, token)
350+
482351
def get_vehicles(self, token: Token) -> list[Vehicle]:
483352
"""Return all Vehicle instances for a given Token"""
484353
url = self.API_URL + "ownr/gvl"

hyundai_kia_connect_api/VehicleManager.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
REGIONS,
3333
VALET_MODE_ACTION,
3434
VEHICLE_LOCK_ACTION,
35+
OTP_NOTIFY_TYPE,
3536
)
3637
from .exceptions import APIError, AuthenticationOTPRequired
3738
from .HyundaiBlueLinkApiBR import HyundaiBlueLinkApiBR
@@ -106,11 +107,17 @@ def login(self) -> bool | OTPRequest:
106107
self.otp_request = result
107108
return result
108109

109-
def send_otp(self, otp_destination: str, otp_via: str) -> None:
110-
self.api.send_otp(self.otp_request, otp_destination, otp_via)
110+
def send_otp(self, notify_type: OTP_NOTIFY_TYPE) -> None:
111+
self.api.send_otp(self.otp_request, notify_type)
111112

112-
def verify_otp(self, otp_code: str) -> None:
113-
self.token = self.api.verify_otp(self.otp_request, otp_code)
113+
def verify_otp_and_complete_login(self, otp_code: str) -> None:
114+
self.token = self.api.verify_otp_and_complete_login(
115+
username=self.username,
116+
password=self.password,
117+
otp_code=otp_code,
118+
otp_request=self.otp_request,
119+
pin=self.pin,
120+
)
114121
self.initialize_vehicles()
115122

116123
def initialize_vehicles(self):

hyundai_kia_connect_api/const.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,8 @@ class WINDOW_STATE(IntEnum):
116116
class VALET_MODE_ACTION(Enum):
117117
ACTIVATE = "activate"
118118
DEACTIVATE = "deactivate"
119+
120+
121+
class OTP_NOTIFY_TYPE(Enum):
122+
EMAIL = "EMAIL"
123+
SMS = "SMS"

0 commit comments

Comments
 (0)