Implement trusted_networks support (#283)
* Implement bypass for trusted_networks * Trusted Network tests * Test cleanup * Improve integration tests * Defensive programming * Fix wrong import issue
This commit is contained in:
committed by
GitHub
parent
04abb0fdb3
commit
c7370ed266
@@ -2,11 +2,13 @@
|
||||
|
||||
import base64
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.auth import InvalidAuthError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -24,6 +26,10 @@ from custom_components.auth_oidc.config.const import (
|
||||
from .mocks.oidc_server import MockOIDCServer, mock_oidc_responses
|
||||
|
||||
FAKE_REDIR_URL = "http://example.com/auth/authorize?response_type=code&redirect_uri=http%3A%2F%2Fexample.com%3A8123%2F%3Fauth_callback%3D1&client_id=http%3A%2F%2Fexample.com%3A8123%2F&state=example"
|
||||
DEFAULT_CONFIG = {
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
}
|
||||
|
||||
|
||||
async def setup(hass: HomeAssistant, config: dict, expect_success: bool) -> bool:
|
||||
@@ -40,10 +46,7 @@ async def test_setup_success_auth_provider_registration(hass: HomeAssistant):
|
||||
"""Test successful setup"""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -62,10 +65,7 @@ async def test_provider_ip_fallback_fails_closed_without_request_context(
|
||||
"""Provider should not invent a shared IP when request context is missing."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -83,10 +83,7 @@ async def test_provider_cookie_header_sets_secure_when_requested(hass: HomeAssis
|
||||
"""Cookie header should include Secure when HTTPS is in use."""
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: "https://example.com/.well-known/openid-configuration",
|
||||
},
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -98,6 +95,105 @@ async def test_provider_cookie_header_sets_secure_when_requested(hass: HomeAssis
|
||||
assert "Secure" in cookie_header
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_is_trusted_network_host_true_for_allowed_ip(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Provider should detect trusted network host when trusted provider allows the IP."""
|
||||
await setup(
|
||||
hass,
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
|
||||
class TrustedNetworksAllowProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
return None
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("trusted_networks", None), TrustedNetworksAllowProvider()),
|
||||
((provider.type, provider.id), provider),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = SimpleNamespace(remote="127.0.0.1")
|
||||
assert provider.is_trusted_network_host() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_is_trusted_network_host_false_for_disallowed_ip(
|
||||
hass: HomeAssistant, caplog
|
||||
):
|
||||
"""Provider should return False when trusted provider denies the current IP."""
|
||||
await setup(
|
||||
hass,
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
|
||||
class TrustedNetworksDenyProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
raise InvalidAuthError("Not in trusted_networks")
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("trusted_networks", None), TrustedNetworksDenyProvider()),
|
||||
((provider.type, provider.id), provider),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = SimpleNamespace(remote="127.0.0.1")
|
||||
assert provider.is_trusted_network_host() is False
|
||||
assert any(
|
||||
level >= 0
|
||||
and "is not in a trusted network, proceeding with OIDC flow" in message
|
||||
for _, level, message in caplog.record_tuples
|
||||
)
|
||||
assert not any(
|
||||
level >= 0 and "Error while validating trusted network for IP" in message
|
||||
for _, level, message in caplog.record_tuples
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_is_trusted_network_host_false_without_trusted_provider(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Provider should return False when trusted_networks auth provider is absent."""
|
||||
await setup(
|
||||
hass,
|
||||
DEFAULT_CONFIG,
|
||||
True,
|
||||
)
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
|
||||
# Without actually getting the IP, should also be false
|
||||
assert provider.is_trusted_network_host() is False
|
||||
|
||||
# With the IP, should be false
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = SimpleNamespace(remote="127.0.0.1")
|
||||
assert provider.is_trusted_network_host() is False
|
||||
|
||||
|
||||
async def login_user(hass: HomeAssistant, state_id: str):
|
||||
"""Helper to login a user from the stored OIDC state."""
|
||||
|
||||
@@ -167,8 +263,7 @@ async def test_full_login(hass: HomeAssistant, hass_client):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -198,8 +293,7 @@ async def test_login_with_linking(hass: HomeAssistant, hass_client):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: True,
|
||||
@@ -233,8 +327,7 @@ async def test_login_with_person_create(hass: HomeAssistant, hass_client):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: True,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -267,8 +360,7 @@ async def test_login_without_person_create_does_not_create_person(
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -295,8 +387,7 @@ async def test_login_shows_form(hass: HomeAssistant):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -319,8 +410,7 @@ async def test_login_with_invalid_cookie_aborts(hass: HomeAssistant):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
@@ -352,8 +442,7 @@ async def test_login_with_no_cookie_aborts(hass: HomeAssistant):
|
||||
await setup(
|
||||
hass,
|
||||
{
|
||||
CLIENT_ID: "dummy",
|
||||
DISCOVERY_URL: MockOIDCServer.get_discovery_url(),
|
||||
**DEFAULT_CONFIG,
|
||||
FEATURES: {
|
||||
FEATURES_AUTOMATIC_PERSON_CREATION: False,
|
||||
FEATURES_AUTOMATIC_USER_LINKING: False,
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
import base64
|
||||
import asyncio
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, unquote, urlparse, urlencode
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from custom_components.auth_oidc import DOMAIN
|
||||
from custom_components.auth_oidc.provider import COOKIE_NAME
|
||||
from custom_components.auth_oidc.tools.oidc_client import (
|
||||
OIDCDiscoveryClient,
|
||||
OIDCDiscoveryInvalid,
|
||||
@@ -248,6 +251,47 @@ async def test_full_oidc_flow(hass: HomeAssistant, hass_client):
|
||||
await verify_back_redirect(client, redirect_uri)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_flow_init_completes_with_state_cookie(
|
||||
hass: HomeAssistant, hass_client
|
||||
):
|
||||
"""The provider login flow init step should finalize when the auth cookie is present."""
|
||||
await setup(hass)
|
||||
|
||||
with mock_oidc_responses():
|
||||
client = await hass_client()
|
||||
redirect_uri = create_redirect_uri(WEB_CLIENT_ID)
|
||||
|
||||
state, _, status = await get_welcome_for_client(client, redirect_uri)
|
||||
assert status == 200
|
||||
|
||||
authorization_url = await get_redirect_auth_url(client)
|
||||
session = async_get_clientsession(hass)
|
||||
resp_auth = session.get(authorization_url, allow_redirects=False)
|
||||
json_auth = await resp_auth.json()
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/oidc/callback?code={json_auth['code']}&state={state}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert resp.status == 302
|
||||
|
||||
provider = hass.auth.get_auth_providers(DOMAIN)[0]
|
||||
flow = await provider.async_login_flow({})
|
||||
|
||||
fake_request = SimpleNamespace(
|
||||
cookies={COOKIE_NAME: state},
|
||||
remote="127.0.0.1",
|
||||
)
|
||||
with patch(
|
||||
"custom_components.auth_oidc.provider.http.current_request"
|
||||
) as current_request:
|
||||
current_request.get.return_value = fake_request
|
||||
result = await flow.async_step_init({})
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def discovery_test_through_redirect(
|
||||
hass_client, caplog, scenario: str, match_log_line: str
|
||||
):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import base64
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse, urlencode
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from auth_oidc.config.const import (
|
||||
@@ -25,6 +26,22 @@ from custom_components.auth_oidc.endpoints.injected_auth_page import (
|
||||
)
|
||||
|
||||
MOBILE_CLIENT_ID = "https://home-assistant.io/Android"
|
||||
WELCOME_PATH = "/auth/oidc/welcome"
|
||||
INJECTION_SCRIPT_MARKER = "<script src='/auth/oidc/static/injection.js"
|
||||
|
||||
|
||||
def assert_redirects_to_welcome(resp) -> None:
|
||||
"""Assert a response redirects to the OIDC welcome endpoint."""
|
||||
assert resp.status == 302
|
||||
location = resp.headers["Location"]
|
||||
parsed_location = urlparse(location)
|
||||
assert parsed_location.path == WELCOME_PATH
|
||||
|
||||
|
||||
async def assert_normal_login_screen(resp) -> None:
|
||||
"""Assert we stayed on the auth page and render the injected normal login HTML."""
|
||||
assert resp.status == 200
|
||||
assert INJECTION_SCRIPT_MARKER in await resp.text()
|
||||
|
||||
|
||||
def create_redirect_uri(client_id: str) -> str:
|
||||
@@ -310,7 +327,9 @@ async def test_welcome_desktop_auto_redirects_without_other_providers(
|
||||
"""Welcome should auto-redirect desktop clients when no other providers exist."""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = [] # Clear initial providers out
|
||||
hass.auth._providers = {} # Clear initial providers out
|
||||
# pylint: enable=protected-access
|
||||
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
@@ -334,7 +353,7 @@ async def test_redirect_without_cookie_goes_to_welcome(
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/oidc/redirect", allow_redirects=False)
|
||||
assert resp.status == 302
|
||||
assert "/auth/oidc/welcome" in resp.headers["Location"]
|
||||
assert_redirects_to_welcome(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -730,10 +749,7 @@ async def test_frontend_injection(
|
||||
|
||||
client = await hass_client()
|
||||
resp = await client.get("/auth/authorize", allow_redirects=False)
|
||||
assert resp.status == 200 # 200 because there is no redirect_uri
|
||||
text = await resp.text()
|
||||
|
||||
assert "<script src='/auth/oidc/static/injection.js" in text
|
||||
await assert_normal_login_screen(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -760,8 +776,12 @@ async def test_frontend_injection_logs_and_returns_when_route_handler_is_unexpec
|
||||
def __iter__(self):
|
||||
return iter([FakeRoute()])
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
with patch.object(hass.http.app.router, "resources", return_value=[FakeResource()]):
|
||||
await frontend_injection(hass, force_https=False)
|
||||
await frontend_injection(
|
||||
hass, provider, force_https=False, has_trusted_networks_provider_first=False
|
||||
)
|
||||
|
||||
assert "Unexpected route handler type" in caplog.text
|
||||
assert (
|
||||
@@ -780,7 +800,10 @@ async def test_injected_auth_page_inject_logs_errors(hass: HomeAssistant, caplog
|
||||
"custom_components.auth_oidc.endpoints.injected_auth_page.frontend_injection",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
await OIDCInjectedAuthPage.inject(hass, force_https=False)
|
||||
provider = MagicMock()
|
||||
await OIDCInjectedAuthPage.inject(
|
||||
hass, provider, force_https=False, has_trusted_networks_provider_first=False
|
||||
)
|
||||
|
||||
assert "Failed to inject OIDC auth page: boom" in caplog.text
|
||||
|
||||
@@ -836,5 +859,71 @@ async def test_injected_auth_page_returns_original_html_when_skipped(
|
||||
client = await hass_client()
|
||||
response = await client.get(request_target, allow_redirects=False)
|
||||
|
||||
assert response.status == 200
|
||||
assert "<script src='/auth/oidc/static/injection.js" in await response.text()
|
||||
await assert_normal_login_screen(response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injected_auth_page_trusted_networks_bypass_skips_oidc_redirect(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
):
|
||||
"""Trusted network hosts should bypass OIDC redirect when trusted_networks is first."""
|
||||
|
||||
class TrustedNetworksAllowProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
return None
|
||||
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[(("trusted_networks", None), TrustedNetworksAllowProvider())]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
await setup_mock_authorize_route(hass)
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
encoded_redirect_uri = quote(create_redirect_uri(client.make_url("/")), safe="")
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/authorize?redirect_uri={encoded_redirect_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
await assert_normal_login_screen(resp)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injected_auth_page_ignores_trusted_networks_when_not_first(
|
||||
hass: HomeAssistant, hass_client: ClientSessionGenerator
|
||||
):
|
||||
"""OIDC redirect should continue when trusted_networks is not the first provider."""
|
||||
|
||||
class DummyProvider:
|
||||
pass
|
||||
|
||||
class TrustedNetworksAllowProvider:
|
||||
def async_validate_access(self, _ip_addr):
|
||||
return None
|
||||
|
||||
# Keep trusted_networks present but not first, so bypass should not apply.
|
||||
# pylint: disable=protected-access
|
||||
hass.auth._providers = OrderedDict(
|
||||
[
|
||||
(("homeassistant", None), DummyProvider()),
|
||||
(("trusted_networks", None), TrustedNetworksAllowProvider()),
|
||||
]
|
||||
)
|
||||
# pylint: enable=protected-access
|
||||
|
||||
await setup_mock_authorize_route(hass)
|
||||
await setup(hass)
|
||||
|
||||
client = await hass_client()
|
||||
encoded_redirect_uri = quote(create_redirect_uri(client.make_url("/")), safe="")
|
||||
|
||||
resp = await client.get(
|
||||
f"/auth/authorize?redirect_uri={encoded_redirect_uri}",
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
assert_redirects_to_welcome(resp)
|
||||
|
||||
Reference in New Issue
Block a user