Skip to content

Commit b77e2d9

Browse files
authored
Merge pull request #1150 from fronzbot/dev
0.25.0
2 parents c7c1ea8 + d2ecc73 commit b77e2d9

File tree

16 files changed

+2272
-164
lines changed

16 files changed

+2272
-164
lines changed

blinkpy/api.py

Lines changed: 478 additions & 30 deletions
Large diffs are not rendered by default.

blinkpy/auth.py

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Login handler for blink."""
22

33
import time
4+
import uuid
45
import logging
56
from aiohttp import (
67
ClientSession,
@@ -10,6 +11,7 @@
1011
)
1112
from blinkpy import api
1213
from blinkpy.helpers import util
14+
from blinkpy.helpers.pkce import generate_pkce_pair
1315
from blinkpy.helpers.constants import (
1416
BLINK_URL,
1517
APP_BUILD,
@@ -67,6 +69,11 @@ def __init__(
6769
# Callback to notify on token refresh
6870
self.callback = callback
6971

72+
# OAuth v2 attributes
73+
self.hardware_id = login_data.get("hardware_id")
74+
if not self.hardware_id:
75+
self.hardware_id = str(uuid.uuid4()).upper()
76+
7077
@property
7178
def login_attributes(self):
7279
"""Return a dictionary of login attributes."""
@@ -79,6 +86,7 @@ def login_attributes(self):
7986
self.data["client_id"] = self.client_id
8087
self.data["account_id"] = self.account_id
8188
self.data["user_id"] = self.user_id
89+
self.data["hardware_id"] = self.hardware_id
8290
return self.data
8391

8492
@property
@@ -176,8 +184,27 @@ def extract_tier_info(self):
176184
async def startup(self):
177185
"""Initialize tokens for communication."""
178186
self.validate_login()
179-
if None in self.login_attributes.values():
180-
await self.refresh_tokens()
187+
188+
if self.refresh_token and self.hardware_id:
189+
_LOGGER.debug("Attempting OAuth v2 token refresh")
190+
try:
191+
token_data = await api.oauth_refresh_token(
192+
self, self.refresh_token, self.hardware_id
193+
)
194+
if token_data:
195+
await self._process_token_data(token_data)
196+
_LOGGER.info("OAuth v2 token refresh successful")
197+
return
198+
except Exception as error:
199+
_LOGGER.debug("OAuth v2 refresh failed: %s", error)
200+
201+
_LOGGER.debug("Attempting OAuth v2 login flow")
202+
success = await self._oauth_login_flow()
203+
if success:
204+
_LOGGER.info("OAuth v2 login successful")
205+
return
206+
207+
raise LoginError("OAuth v2 login failed")
181208

182209
async def validate_response(self, response: ClientResponse, json_resp):
183210
"""Check for valid response."""
@@ -271,6 +298,136 @@ async def query(
271298
)
272299
return None
273300

301+
async def _oauth_login_flow(self):
302+
"""
303+
Execute complete OAuth 2.0 login flow with PKCE.
304+
305+
Returns:
306+
bool: True if successful
307+
308+
"""
309+
# Step 1: Generate PKCE
310+
code_verifier, code_challenge = generate_pkce_pair()
311+
312+
# Step 2: Authorization request
313+
auth_success = await api.oauth_authorize_request(
314+
self, self.hardware_id, code_challenge
315+
)
316+
if not auth_success:
317+
_LOGGER.error("OAuth authorization request failed")
318+
return False
319+
320+
# Step 3: Get CSRF token
321+
csrf_token = await api.oauth_get_signin_page(self)
322+
if not csrf_token:
323+
_LOGGER.error("Failed to get CSRF token")
324+
return False
325+
326+
# Step 4: Login
327+
email = self.data.get("username")
328+
password = self.data.get("password")
329+
330+
login_result = await api.oauth_signin(self, email, password, csrf_token)
331+
332+
# Step 4b: Handle 2FA if needed
333+
if login_result == "2FA_REQUIRED":
334+
# Store CSRF token and verifier for later use
335+
self._oauth_csrf_token = csrf_token
336+
self._oauth_code_verifier = code_verifier
337+
# Raise exception to let the app handle 2FA prompt
338+
_LOGGER.info("Two-factor authentication required.")
339+
raise BlinkTwoFARequiredError
340+
elif login_result != "SUCCESS":
341+
_LOGGER.error("Login failed")
342+
return False
343+
344+
# Step 5: Get authorization code
345+
code = await api.oauth_get_authorization_code(self)
346+
if not code:
347+
_LOGGER.error("Failed to get authorization code")
348+
return False
349+
350+
# Step 6: Exchange code for token
351+
token_data = await api.oauth_exchange_code_for_token(
352+
self, code, code_verifier, self.hardware_id
353+
)
354+
355+
if not token_data:
356+
_LOGGER.error("Failed to exchange code for token")
357+
return False
358+
359+
# Process tokens
360+
await self._process_token_data(token_data)
361+
return True
362+
363+
async def _process_token_data(self, token_data):
364+
"""Process token response data."""
365+
self.token = token_data.get("access_token")
366+
self.refresh_token = token_data.get("refresh_token")
367+
368+
# Set expiration
369+
expires_in = token_data.get("expires_in", 3600)
370+
self.expires_in = expires_in
371+
self.expiration_date = time.time() + expires_in
372+
373+
# Get tier info if needed (for account_id, region_id, host)
374+
if not self.host or not self.region_id or not self.account_id:
375+
try:
376+
self.tier_info = await self.get_tier_info()
377+
self.extract_tier_info()
378+
except Exception as error:
379+
_LOGGER.warning("Failed to get tier info: %s", error)
380+
381+
async def complete_2fa_login(self, twofa_code):
382+
"""
383+
Complete OAuth v2 login after 2FA verification.
384+
385+
Args:
386+
twofa_code: 2FA code from user
387+
388+
Returns:
389+
bool: True if successful
390+
391+
"""
392+
# Check if we have stored OAuth state
393+
if not hasattr(self, "_oauth_csrf_token") or not hasattr(
394+
self, "_oauth_code_verifier"
395+
):
396+
_LOGGER.error("No OAuth 2FA state found. Start login flow first.")
397+
return False
398+
399+
csrf_token = self._oauth_csrf_token
400+
code_verifier = self._oauth_code_verifier
401+
402+
# Verify 2FA
403+
if not await api.oauth_verify_2fa(self, csrf_token, twofa_code):
404+
_LOGGER.error("2FA verification failed")
405+
return False
406+
407+
# Step 5: Get authorization code
408+
code = await api.oauth_get_authorization_code(self)
409+
if not code:
410+
_LOGGER.error("Failed to get authorization code after 2FA")
411+
return False
412+
413+
# Step 6: Exchange code for token
414+
token_data = await api.oauth_exchange_code_for_token(
415+
self, code, code_verifier, self.hardware_id
416+
)
417+
418+
if not token_data:
419+
_LOGGER.error("Failed to exchange code for token after 2FA")
420+
return False
421+
422+
# Process tokens
423+
await self._process_token_data(token_data)
424+
425+
# Clean up temporary state
426+
delattr(self, "_oauth_csrf_token")
427+
delattr(self, "_oauth_code_verifier")
428+
429+
return True
430+
274431

275432
class TokenRefreshFailed(Exception):
276433
"""Class to throw failed refresh exception."""

blinkpy/blinkpy.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,30 @@ async def prompt_2fa(self):
9797

9898
async def send_2fa_code(self, code):
9999
"""Send the two-factor authentication code to complete login."""
100-
self.auth.data["2fa_code"] = code
101-
await self.start()
100+
# Complete OAuth v2 2FA flow
101+
success = await self.auth.complete_2fa_login(code)
102+
if not success:
103+
_LOGGER.error("OAuth v2 2FA completion failed.")
104+
return False
105+
106+
# Continue setup flow - same steps as start() after auth.startup()
107+
try:
108+
self.setup_urls()
109+
await self.get_homescreen()
110+
except BlinkSetupError:
111+
_LOGGER.error("Cannot setup Blink platform after 2FA.")
112+
self.available = False
113+
return False
114+
115+
if not self.last_refresh:
116+
self.last_refresh = int(time.time() - self.refresh_rate * 1.05)
117+
_LOGGER.debug(
118+
"Initialized last_refresh to %s == %s",
119+
self.last_refresh,
120+
datetime.datetime.fromtimestamp(self.last_refresh),
121+
)
122+
123+
return await self.setup_post_verify()
102124

103125
@util.Throttle(seconds=MIN_THROTTLE_TIME)
104126
async def refresh(self, force=False, force_cache=False):

0 commit comments

Comments
 (0)