oauth.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """OAuth helpers for the SwitchBot account API."""
  2. from __future__ import annotations
  3. import logging
  4. from collections.abc import Mapping
  5. from time import monotonic
  6. from typing import Any
  7. from urllib.parse import urlencode, urlsplit
  8. import aiohttp
  9. from .const import (
  10. SwitchbotAccountConnectionError,
  11. SwitchbotApiError,
  12. SwitchbotAuthenticationError,
  13. )
  14. from .utils import extract_request_id
  15. _LOGGER = logging.getLogger(__name__)
  16. OAUTH_AUTHORIZE_URL = "https://sp.oauth.switchbot.net"
  17. OAUTH_TOKEN_URL = "https://account.api.switchbot.net/merchant/v1/oauth/token"
  18. OAUTH_SCOPE = "api_login"
  19. def _oauth_error_field(
  20. error_data: Any, field: str, authorization_code: str
  21. ) -> str | None:
  22. """Return a bounded OAuth error field with the authorization code redacted."""
  23. if not isinstance(error_data, Mapping):
  24. return None
  25. value = error_data.get(field)
  26. if not isinstance(value, str):
  27. return None
  28. value = " ".join(value.split())
  29. if authorization_code:
  30. value = value.replace(authorization_code, "<redacted>")
  31. return value[:256] or None
  32. def _raise_for_oauth_error(status: int, error: str | None, error_suffix: str) -> None:
  33. """Raise the appropriate exception for an OAuth error response."""
  34. if status in (401, 403) or (
  35. status == 400 and error in {"invalid_client", "invalid_grant"}
  36. ):
  37. raise SwitchbotAuthenticationError(
  38. f"SwitchBot OAuth token request rejected ({status}){error_suffix}"
  39. )
  40. if 400 <= status < 500 and status != 429:
  41. raise SwitchbotApiError(
  42. f"SwitchBot OAuth token request failed ({status}){error_suffix}"
  43. )
  44. if status == 429 or status >= 500:
  45. raise SwitchbotAccountConnectionError(
  46. f"SwitchBot OAuth token service unavailable ({status}){error_suffix}"
  47. )
  48. def build_oauth_authorize_url(
  49. client_id: str,
  50. redirect_uri: str,
  51. state: str,
  52. ) -> str:
  53. """Build a SwitchBot OAuth authorization URL."""
  54. _LOGGER.debug(
  55. "Building SwitchBot OAuth authorization request; authorize_host=%s "
  56. "redirect_host=%s",
  57. urlsplit(OAUTH_AUTHORIZE_URL).hostname,
  58. urlsplit(redirect_uri).hostname,
  59. )
  60. query = urlencode(
  61. {
  62. "client_id": client_id,
  63. "redirect_uri": redirect_uri,
  64. "response_type": "code",
  65. "scope": OAUTH_SCOPE,
  66. "state": state,
  67. }
  68. )
  69. return f"{OAUTH_AUTHORIZE_URL}?{query}"
  70. async def exchange_oauth_code(
  71. session: aiohttp.ClientSession,
  72. client_id: str,
  73. redirect_uri: str,
  74. code: str,
  75. ) -> dict[str, Any]:
  76. """Exchange an OAuth authorization code for a SwitchBot access token."""
  77. started = monotonic()
  78. _LOGGER.debug(
  79. "Exchanging SwitchBot OAuth authorization code; token_host=%s",
  80. urlsplit(OAUTH_TOKEN_URL).hostname,
  81. )
  82. error: str | None = None
  83. error_description: str | None = None
  84. token_data: Any = None
  85. try:
  86. async with session.post(
  87. OAUTH_TOKEN_URL,
  88. data={
  89. "code": code,
  90. "client_id": client_id,
  91. "grant_type": "authorization_code",
  92. "redirect_uri": redirect_uri,
  93. },
  94. timeout=aiohttp.ClientTimeout(total=10),
  95. ) as response:
  96. status = response.status
  97. _LOGGER.debug(
  98. "SwitchBot OAuth token endpoint returned HTTP status %s; "
  99. "duration_ms=%s request_id=%s",
  100. status,
  101. round((monotonic() - started) * 1000),
  102. extract_request_id(response.headers) or "unavailable",
  103. )
  104. if status >= 400:
  105. try:
  106. error_data = await response.json()
  107. except (aiohttp.ClientError, ValueError, TypeError) as err:
  108. _LOGGER.debug(
  109. "SwitchBot OAuth token error response could not be parsed; "
  110. "error_type=%s",
  111. type(err).__name__,
  112. )
  113. error_data = None
  114. error = _oauth_error_field(error_data, "error", code)
  115. error_description = _oauth_error_field(
  116. error_data, "error_description", code
  117. )
  118. _LOGGER.debug(
  119. "SwitchBot OAuth token endpoint returned an error response; "
  120. "error=%s error_description=%s",
  121. error or "unavailable",
  122. error_description or "unavailable",
  123. )
  124. else:
  125. try:
  126. token_data = await response.json()
  127. except (aiohttp.ClientError, ValueError, TypeError) as err:
  128. raise SwitchbotApiError(
  129. "Invalid response from SwitchBot OAuth token API"
  130. ) from err
  131. except (aiohttp.ClientError, TimeoutError) as err:
  132. raise SwitchbotAccountConnectionError(
  133. f"Failed to connect to SwitchBot OAuth token API: {err}"
  134. ) from err
  135. error_detail = ": ".join(
  136. value for value in (error, error_description) if value is not None
  137. )
  138. error_suffix = f": {error_detail}" if error_detail else ""
  139. _raise_for_oauth_error(status, error, error_suffix)
  140. if not isinstance(token_data, dict):
  141. raise SwitchbotApiError("Invalid response from SwitchBot OAuth token API")
  142. token: dict[str, Any] = token_data.copy()
  143. _LOGGER.debug("SwitchBot OAuth token response fields: %s", sorted(token))
  144. access_token = token.get("access_token")
  145. expires_in = token.get("expires_in")
  146. if (
  147. not isinstance(access_token, str)
  148. or not access_token
  149. or isinstance(expires_in, bool)
  150. or not isinstance(expires_in, int | str)
  151. ):
  152. raise SwitchbotApiError("Invalid token data from SwitchBot OAuth token API")
  153. try:
  154. normalized_expires_in = int(expires_in)
  155. except ValueError as err:
  156. raise SwitchbotApiError(
  157. "Invalid token data from SwitchBot OAuth token API"
  158. ) from err
  159. token["expires_in"] = normalized_expires_in
  160. _LOGGER.debug(
  161. "SwitchBot OAuth token response validated; expires_in=%s "
  162. "refresh_token_present=%s refresh_expires_in_present=%s",
  163. normalized_expires_in,
  164. bool(token.get("refresh_token")),
  165. "refresh_expires_in" in token,
  166. )
  167. return token