Home Assistant Unofficial Reference 2024.12.1
indieauth.py
Go to the documentation of this file.
1 """Helpers to resolve client ID/secret."""
2 
3 from __future__ import annotations
4 
5 from html.parser import HTMLParser
6 from ipaddress import ip_address
7 import logging
8 from urllib.parse import ParseResult, urljoin, urlparse
9 
10 import aiohttp
11 import aiohttp.client_exceptions
12 
13 from homeassistant.core import HomeAssistant
14 from homeassistant.util.network import is_local
15 
16 _LOGGER = logging.getLogger(__name__)
17 
18 
20  hass: HomeAssistant, client_id: str, redirect_uri: str
21 ) -> bool:
22  """Verify that the client and redirect uri match."""
23  try:
24  client_id_parts = _parse_client_id(client_id)
25  except ValueError:
26  return False
27 
28  redirect_parts = _parse_url(redirect_uri)
29 
30  # Verify redirect url and client url have same scheme and domain.
31  is_valid = (
32  client_id_parts.scheme == redirect_parts.scheme
33  and client_id_parts.netloc == redirect_parts.netloc
34  )
35 
36  if is_valid:
37  return True
38 
39  # Whitelist the iOS and Android callbacks so that people can link apps
40  # without being connected to the internet.
41  if (
42  client_id == "https://home-assistant.io/iOS"
43  and redirect_uri == "homeassistant://auth-callback"
44  ):
45  return True
46 
47  if client_id == "https://home-assistant.io/android" and redirect_uri in (
48  "homeassistant://auth-callback",
49  "https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android",
50  "https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android",
51  ):
52  return True
53 
54  # IndieAuth 4.2.2 allows for redirect_uri to be on different domain
55  # but needs to be specified in link tag when fetching `client_id`.
56  redirect_uris = await fetch_redirect_uris(hass, client_id)
57  return redirect_uri in redirect_uris
58 
59 
60 class LinkTagParser(HTMLParser):
61  """Parser to find link tags."""
62 
63  def __init__(self, rel: str) -> None:
64  """Initialize a link tag parser."""
65  super().__init__()
66  self.relrel = rel
67  self.found: list[str | None] = []
68 
69  def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
70  """Handle finding a start tag."""
71  if tag != "link":
72  return
73 
74  attributes: dict[str, str | None] = dict(attrs)
75 
76  if attributes.get("rel") == self.relrel:
77  self.found.append(attributes.get("href"))
78 
79 
80 async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]:
81  """Find link tag with redirect_uri values.
82 
83  IndieAuth 4.2.2
84 
85  The client SHOULD publish one or more <link> tags or Link HTTP headers with
86  a rel attribute of redirect_uri at the client_id URL.
87 
88  We limit to the first 10kB of the page.
89 
90  We do not implement extracting redirect uris from headers.
91  """
92  parser = LinkTagParser("redirect_uri")
93  chunks = 0
94  try:
95  async with (
96  aiohttp.ClientSession() as session,
97  session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp,
98  ):
99  async for data in resp.content.iter_chunked(1024):
100  parser.feed(data.decode())
101  chunks += 1
102 
103  if chunks == 10:
104  break
105 
106  except TimeoutError:
107  _LOGGER.error("Timeout while looking up redirect_uri %s", url)
108  except aiohttp.client_exceptions.ClientSSLError:
109  _LOGGER.error("SSL error while looking up redirect_uri %s", url)
110  except aiohttp.client_exceptions.ClientOSError as ex:
111  _LOGGER.error("OS error while looking up redirect_uri %s: %s", url, ex.strerror)
112  except aiohttp.client_exceptions.ClientConnectionError:
113  _LOGGER.error(
114  "Low level connection error while looking up redirect_uri %s", url
115  )
116  except aiohttp.client_exceptions.ClientError:
117  _LOGGER.error("Unknown error while looking up redirect_uri %s", url)
118 
119  # Authorization endpoints verifying that a redirect_uri is allowed for use
120  # by a client MUST look for an exact match of the given redirect_uri in the
121  # request against the list of redirect_uris discovered after resolving any
122  # relative URLs.
123  return [urljoin(url, found) for found in parser.found]
124 
125 
126 def verify_client_id(client_id: str) -> bool:
127  """Verify that the client id is valid."""
128  try:
129  _parse_client_id(client_id)
130  except ValueError:
131  return False
132  return True
133 
134 
135 def _parse_url(url: str) -> ParseResult:
136  """Parse a url in parts and canonicalize according to IndieAuth."""
137  parts = urlparse(url)
138 
139  # Canonicalize a url according to IndieAuth 3.2.
140 
141  # SHOULD convert the hostname to lowercase
142  parts = parts._replace(netloc=parts.netloc.lower())
143 
144  # If a URL with no path component is ever encountered,
145  # it MUST be treated as if it had the path /.
146  if parts.path == "":
147  parts = parts._replace(path="/")
148 
149  return parts
150 
151 
152 def _parse_client_id(client_id: str) -> ParseResult:
153  """Test if client id is a valid URL according to IndieAuth section 3.2.
154 
155  https://indieauth.spec.indieweb.org/#client-identifier
156  """
157  parts = _parse_url(client_id)
158 
159  # Client identifier URLs
160  # MUST have either an https or http scheme
161  if parts.scheme not in ("http", "https"):
162  raise ValueError
163 
164  # MUST contain a path component
165  # Handled by url canonicalization.
166 
167  # MUST NOT contain single-dot or double-dot path segments
168  if any(segment in (".", "..") for segment in parts.path.split("/")):
169  raise ValueError(
170  "Client ID cannot contain single-dot or double-dot path segments"
171  )
172 
173  # MUST NOT contain a fragment component
174  if parts.fragment != "":
175  raise ValueError("Client ID cannot contain a fragment")
176 
177  # MUST NOT contain a username or password component
178  if parts.username is not None:
179  raise ValueError("Client ID cannot contain username")
180 
181  if parts.password is not None:
182  raise ValueError("Client ID cannot contain password")
183 
184  # MAY contain a port
185  try:
186  # parts raises ValueError when port cannot be parsed as int
187  _ = parts.port
188  except ValueError as ex:
189  raise ValueError("Client ID contains invalid port") from ex
190 
191  # Additionally, hostnames
192  # MUST be domain names or a loopback interface and
193  # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1
194  # or IPv6 [::1]
195 
196  # We are not goint to follow the spec here. We are going to allow
197  # any internal network IP to be used inside a client id.
198 
199  address = None
200 
201  try:
202  netloc = parts.netloc
203 
204  # Strip the [, ] from ipv6 addresses before parsing
205  if netloc[0] == "[" and netloc[-1] == "]":
206  netloc = netloc[1:-1]
207 
208  address = ip_address(netloc)
209  except ValueError:
210  # Not an ip address
211  pass
212 
213  if address is None or is_local(address):
214  return parts
215 
216  raise ValueError("Hostname should be a domain name or local IP address")
None handle_starttag(self, str tag, list[tuple[str, str|None]] attrs)
Definition: indieauth.py:69
ParseResult _parse_client_id(str client_id)
Definition: indieauth.py:152
bool verify_redirect_uri(HomeAssistant hass, str client_id, str redirect_uri)
Definition: indieauth.py:21
bool verify_client_id(str client_id)
Definition: indieauth.py:126
list[str] fetch_redirect_uris(HomeAssistant hass, str url)
Definition: indieauth.py:80
bool is_local(ConfigEntry entry)
Definition: __init__.py:58