mirror of
https://github.com/home-assistant/core.git
synced 2026-08-23 14:43:52 +01:00
Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
647 lines
21 KiB
Python
647 lines
21 KiB
Python
"""Tests for the client validator."""
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import aiohttp
|
|
import pytest
|
|
|
|
from homeassistant.components.auth import indieauth
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_session():
|
|
"""Mock aiohttp.ClientSession."""
|
|
mocker = AiohttpClientMocker()
|
|
|
|
with patch(
|
|
"aiohttp.ClientSession",
|
|
side_effect=lambda *args, **kwargs: mocker.create_session(
|
|
asyncio.get_event_loop()
|
|
),
|
|
):
|
|
yield mocker
|
|
|
|
|
|
def test_client_id_scheme() -> None:
|
|
"""Test we enforce valid scheme."""
|
|
assert indieauth._parse_client_id("http://ex.com/")
|
|
assert indieauth._parse_client_id("https://ex.com/")
|
|
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("ftp://ex.com")
|
|
|
|
|
|
def test_client_id_path() -> None:
|
|
"""Test we enforce valid path."""
|
|
assert indieauth._parse_client_id("http://ex.com").path == "/"
|
|
assert indieauth._parse_client_id("http://ex.com/hello").path == "/hello"
|
|
assert (
|
|
indieauth._parse_client_id("http://ex.com/hello/.world").path == "/hello/.world"
|
|
)
|
|
assert (
|
|
indieauth._parse_client_id("http://ex.com/hello./.world").path
|
|
== "/hello./.world"
|
|
)
|
|
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("http://ex.com/.")
|
|
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("http://ex.com/hello/./yo")
|
|
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("http://ex.com/hello/../yo")
|
|
|
|
|
|
def test_client_id_fragment() -> None:
|
|
"""Test we enforce valid fragment."""
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("http://ex.com/#yoo")
|
|
|
|
|
|
def test_client_id_user_pass() -> None:
|
|
"""Test we enforce valid username/password."""
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("http://user@ex.com/")
|
|
|
|
with pytest.raises(ValueError):
|
|
indieauth._parse_client_id("http://user:pass@ex.com/")
|
|
|
|
|
|
def test_client_id_hostname() -> None:
|
|
"""Test we enforce valid hostname."""
|
|
assert indieauth._parse_client_id("http://www.home-assistant.io/")
|
|
assert indieauth._parse_client_id("http://[::1]")
|
|
assert indieauth._parse_client_id("http://127.0.0.1")
|
|
assert indieauth._parse_client_id("http://10.0.0.0")
|
|
assert indieauth._parse_client_id("http://10.255.255.255")
|
|
assert indieauth._parse_client_id("http://172.16.0.0")
|
|
assert indieauth._parse_client_id("http://172.31.255.255")
|
|
assert indieauth._parse_client_id("http://192.168.0.0")
|
|
assert indieauth._parse_client_id("http://192.168.255.255")
|
|
|
|
with pytest.raises(ValueError):
|
|
assert indieauth._parse_client_id("http://255.255.255.255/")
|
|
with pytest.raises(ValueError):
|
|
assert indieauth._parse_client_id("http://11.0.0.0/")
|
|
with pytest.raises(ValueError):
|
|
assert indieauth._parse_client_id("http://172.32.0.0/")
|
|
with pytest.raises(ValueError):
|
|
assert indieauth._parse_client_id("http://192.167.0.0/")
|
|
|
|
|
|
def test_parse_url_lowercase_host() -> None:
|
|
"""Test we update empty paths."""
|
|
assert indieauth._parse_url("http://ex.com/hello").path == "/hello"
|
|
assert indieauth._parse_url("http://EX.COM/hello").hostname == "ex.com"
|
|
|
|
parts = indieauth._parse_url("http://EX.COM:123/HELLO")
|
|
assert parts.netloc == "ex.com:123"
|
|
assert parts.path == "/HELLO"
|
|
|
|
|
|
def test_parse_url_path() -> None:
|
|
"""Test we update empty paths."""
|
|
assert indieauth._parse_url("http://ex.com").path == "/"
|
|
|
|
|
|
async def test_verify_redirect_uri() -> None:
|
|
"""Test that we verify redirect uri correctly."""
|
|
assert await indieauth.verify_redirect_uri(
|
|
None, "http://ex.com", "http://ex.com/callback"
|
|
)
|
|
|
|
with patch.object(indieauth, "fetch_redirect_uris", return_value=[]):
|
|
# Different domain
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None, "http://ex.com", "http://different.com/callback"
|
|
)
|
|
|
|
# Different scheme
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None, "http://ex.com", "https://ex.com/callback"
|
|
)
|
|
|
|
# Different subdomain
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None, "https://sub1.ex.com", "https://sub2.ex.com/callback"
|
|
)
|
|
|
|
|
|
async def test_find_link_tag(hass: HomeAssistant, mock_session) -> None:
|
|
"""Test finding link tag."""
|
|
mock_session.get(
|
|
"http://127.0.0.1:8000",
|
|
text="""
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<link rel="redirect_uri" href="hass://oauth2_redirect">
|
|
<link rel="other_value" href="hass://oauth2_redirect">
|
|
<link rel="redirect_uri" href="/beer">
|
|
</head>
|
|
...
|
|
</html>
|
|
""",
|
|
)
|
|
redirect_uris = await indieauth.fetch_redirect_uris(hass, "http://127.0.0.1:8000")
|
|
|
|
assert redirect_uris == ["hass://oauth2_redirect", "http://127.0.0.1:8000/beer"]
|
|
|
|
|
|
async def test_find_link_tag_max_size(hass: HomeAssistant, mock_session) -> None:
|
|
"""Test finding link tag."""
|
|
text = "".join(
|
|
[
|
|
'<link rel="redirect_uri" href="/wine">',
|
|
("0" * 1024 * 10),
|
|
'<link rel="redirect_uri" href="/beer">',
|
|
]
|
|
)
|
|
mock_session.get("http://127.0.0.1:8000", text=text)
|
|
redirect_uris = await indieauth.fetch_redirect_uris(hass, "http://127.0.0.1:8000")
|
|
|
|
assert redirect_uris == ["http://127.0.0.1:8000/wine"]
|
|
|
|
|
|
async def test_find_link_tag_without_href(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a redirect_uri link tag without a usable href is skipped."""
|
|
mock_session.get(
|
|
"http://127.0.0.1:8000",
|
|
text="""
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<link rel="redirect_uri">
|
|
<link rel="redirect_uri" href="">
|
|
<link rel="redirect_uri" href="https://example.com/cb">
|
|
</head>
|
|
</html>
|
|
""",
|
|
)
|
|
redirect_uris = await indieauth.fetch_redirect_uris(hass, "http://127.0.0.1:8000")
|
|
|
|
assert redirect_uris == ["https://example.com/cb"]
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test fetching redirect uris from a client id metadata document."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": [
|
|
"https://example.com/callback",
|
|
"https://other.com/callback",
|
|
],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
redirect_uris = await indieauth.fetch_redirect_uris(
|
|
hass, "https://example.com/client"
|
|
)
|
|
|
|
assert redirect_uris == [
|
|
"https://example.com/callback",
|
|
"https://other.com/callback",
|
|
]
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_text_plain(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test the metadata document is parsed regardless of content type."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
}
|
|
),
|
|
headers={"Content-Type": "text/plain"},
|
|
)
|
|
redirect_uris = await indieauth.fetch_redirect_uris(
|
|
hass, "https://example.com/client"
|
|
)
|
|
|
|
assert redirect_uris == ["https://example.com/callback"]
|
|
|
|
|
|
async def test_fetch_redirect_uris_link_tag_precedence(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test link tags take precedence over metadata document parsing."""
|
|
mock_session.get(
|
|
"http://127.0.0.1:8000",
|
|
text="""
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<link rel="redirect_uri" href="hass://oauth2_redirect">
|
|
</head>
|
|
<body>
|
|
{"redirect_uris": ["https://example.com/should-be-ignored"]}
|
|
</body>
|
|
</html>
|
|
""",
|
|
)
|
|
redirect_uris = await indieauth.fetch_redirect_uris(hass, "http://127.0.0.1:8000")
|
|
|
|
assert redirect_uris == ["hass://oauth2_redirect"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
pytest.param("this is neither json nor html", id="not-json-not-html"),
|
|
pytest.param('["https://example.com/callback"]', id="json-array"),
|
|
pytest.param("42", id="json-scalar"),
|
|
pytest.param(
|
|
json.dumps({"redirect_uris": ["https://example.com/callback"]}),
|
|
id="missing-client-id",
|
|
),
|
|
pytest.param(
|
|
json.dumps({"client_id": "https://example.com/client"}),
|
|
id="missing-redirect-uris",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": [],
|
|
}
|
|
),
|
|
id="empty-redirect-uris",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://other.example/client",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
}
|
|
),
|
|
id="client-id-mismatch",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": "https://example.com/callback",
|
|
}
|
|
),
|
|
id="redirect-uris-not-list",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback", 123],
|
|
}
|
|
),
|
|
id="redirect-uris-non-string-entry",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["/callback"],
|
|
}
|
|
),
|
|
id="redirect-uris-relative-entry",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback#fragment"],
|
|
}
|
|
),
|
|
id="redirect-uris-fragment-entry",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://["],
|
|
}
|
|
),
|
|
id="redirect-uris-unparsable-entry",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback#"],
|
|
}
|
|
),
|
|
id="redirect-uris-empty-fragment-entry",
|
|
),
|
|
pytest.param(
|
|
json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com:not-a-port/callback"],
|
|
}
|
|
),
|
|
id="redirect-uris-invalid-port-entry",
|
|
),
|
|
pytest.param(
|
|
'{"client_id": "https://example.com/client",'
|
|
' "redirect_uris": ["https://example.com/callback"], "x": NaN}',
|
|
id="json-nan-constant",
|
|
),
|
|
],
|
|
)
|
|
async def test_fetch_redirect_uris_metadata_document_invalid(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker, text: str
|
|
) -> None:
|
|
"""Test that invalid metadata documents yield no redirect uris."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=text,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
assert not await indieauth.verify_redirect_uri(
|
|
hass, "https://example.com/client", "https://other.com/callback"
|
|
)
|
|
|
|
|
|
async def test_verify_redirect_uri_metadata_document(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test verifying a cross-origin redirect uri from a metadata document."""
|
|
client_id = "https://example.com/client"
|
|
mock_session.get(
|
|
client_id,
|
|
text=json.dumps(
|
|
{
|
|
"client_id": client_id,
|
|
"redirect_uris": ["https://other.com/callback"],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.verify_redirect_uri(
|
|
hass, client_id, "https://other.com/callback"
|
|
)
|
|
|
|
assert not await indieauth.verify_redirect_uri(
|
|
hass, client_id, "https://other.com/not-listed"
|
|
)
|
|
|
|
|
|
async def test_verify_redirect_uri_unparsable(hass: HomeAssistant) -> None:
|
|
"""Test an unparsable requested redirect uri is rejected without raising."""
|
|
assert not await indieauth.verify_redirect_uri(
|
|
hass, "https://example.com/client", "https://["
|
|
)
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_invalid_utf8(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a metadata document with invalid UTF-8 is rejected."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
content=(
|
|
b'{"client_id": "https://example.com/client",'
|
|
b' "redirect_uris": ["https://other.com/callback"], "note": "\xff"}'
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"client_id",
|
|
[
|
|
pytest.param("https://example.com", id="no-path"),
|
|
pytest.param("https://example.com/client#", id="empty-fragment"),
|
|
],
|
|
)
|
|
async def test_fetch_redirect_uris_metadata_document_invalid_client_id(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker, client_id: str
|
|
) -> None:
|
|
"""Test client ids violating the metadata document URL rules are ignored."""
|
|
mock_session.get(
|
|
client_id,
|
|
text=json.dumps(
|
|
{
|
|
"client_id": client_id,
|
|
"redirect_uris": ["https://other.com/callback"],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, client_id) == []
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_not_ok(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a metadata document not served with 200 OK is ignored."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
}
|
|
),
|
|
status=404,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_http_scheme(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a metadata document served over http is ignored."""
|
|
client_id = "http://example.com/client"
|
|
mock_session.get(
|
|
client_id,
|
|
text=json.dumps(
|
|
{
|
|
"client_id": client_id,
|
|
"redirect_uris": ["https://other.com/callback"],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, client_id) == []
|
|
assert not await indieauth.verify_redirect_uri(
|
|
hass, client_id, "https://other.com/callback"
|
|
)
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_redirected(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a metadata document reached via a redirect is ignored."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
history=(object(),),
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_private_use_scheme(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a private-use scheme redirect uri is accepted as an absolute URI."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["app:/oauth-callback"],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == [
|
|
"app:/oauth-callback"
|
|
]
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_oversized(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a document past the 10kB cap is rejected as an incomplete read."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://example.com/callback"],
|
|
"padding": "x" * 11000,
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_exactly_at_cap(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a document of exactly the read cap is rejected as possibly truncated."""
|
|
document = {
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": ["https://other.com/callback"],
|
|
"padding": "",
|
|
}
|
|
document["padding"] = "x" * (10240 - len(json.dumps(document)))
|
|
text = json.dumps(document)
|
|
assert len(text) == 10240
|
|
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=text,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
async def test_fetch_redirect_uris_metadata_document_at_cap_ineligible(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a valid document that reaches the 10kB cap is ineligible."""
|
|
mock_session.get(
|
|
"https://example.com/client",
|
|
text=json.dumps(
|
|
{
|
|
"client_id": "https://example.com/client",
|
|
"redirect_uris": [
|
|
f"https://example.com/callback/{index}" for index in range(400)
|
|
],
|
|
}
|
|
),
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
async def test_fetch_redirect_uris_network_error(
|
|
hass: HomeAssistant, mock_session: AiohttpClientMocker
|
|
) -> None:
|
|
"""Test a network error yields no redirect uris without raising."""
|
|
mock_session.get("https://example.com/client", exc=aiohttp.ClientError())
|
|
|
|
assert await indieauth.fetch_redirect_uris(hass, "https://example.com/client") == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"client_id",
|
|
["https://home-assistant.io/android", "https://home-assistant.io/iOS"],
|
|
)
|
|
async def test_verify_redirect_uri_android_ios(client_id) -> None:
|
|
"""Test that we verify redirect uri correctly for Android/iOS."""
|
|
with patch.object(indieauth, "fetch_redirect_uris", return_value=[]):
|
|
assert await indieauth.verify_redirect_uri(
|
|
None, client_id, "homeassistant://auth-callback"
|
|
)
|
|
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None, client_id, "homeassistant://something-else"
|
|
)
|
|
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None, "https://incorrect.com", "homeassistant://auth-callback"
|
|
)
|
|
|
|
if client_id == "https://home-assistant.io/android":
|
|
assert await indieauth.verify_redirect_uri(
|
|
None,
|
|
client_id,
|
|
"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android",
|
|
)
|
|
assert await indieauth.verify_redirect_uri(
|
|
None,
|
|
client_id,
|
|
"https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android",
|
|
)
|
|
else:
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None,
|
|
client_id,
|
|
"https://wear.googleapis.com/3p_auth/io.homeassistant.companion.android",
|
|
)
|
|
assert not await indieauth.verify_redirect_uri(
|
|
None,
|
|
client_id,
|
|
"https://wear.googleapis-cn.com/3p_auth/io.homeassistant.companion.android",
|
|
)
|