From 191f49a3260f5463f73c9fabccde8d2e794b8115 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Mar 2026 00:06:48 -0700 Subject: [PATCH] Add RFC9728 OAuth2 Protected Resource metadata endpoint (#166213) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/auth/login_flow.py | 27 +++++++++ homeassistant/helpers/http.py | 18 +++++- tests/components/auth/test_login_flow.py | 67 +++++++++++++++++++++ tests/components/http/test_view.py | 46 +++++++++++++- 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index a425c123e3e..235d5b4c338 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -115,6 +115,7 @@ def async_setup( ) -> None: """Component to allow users to login.""" hass.http.register_view(WellKnownOAuthInfoView) + hass.http.register_view(WellKnownProtectedResourceView) hass.http.register_view(AuthProvidersView) hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result)) hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result)) @@ -154,6 +155,32 @@ class WellKnownOAuthInfoView(HomeAssistantView): return self.json(metadata) +class WellKnownProtectedResourceView(HomeAssistantView): + """View to host the OAuth2 Protected Resource Metadata per RFC9728.""" + + requires_auth = False + url = "/.well-known/oauth-protected-resource" + name = "well-known/oauth-protected-resource" + + async def get(self, request: web.Request) -> web.Response: + """Return the protected resource metadata.""" + hass = request.app[KEY_HASS] + try: + url_prefix = get_url(hass, require_current_request=True) + except NoURLAvailableError: + return self.json_message("No URL available", HTTPStatus.NOT_FOUND) + + return self.json( + { + "resource": url_prefix, + "authorization_servers": [url_prefix], + "resource_documentation": ( + "https://developers.home-assistant.io/docs/auth_api" + ), + } + ) + + class AuthProvidersView(HomeAssistantView): """View to get available auth providers.""" diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index e890a8ed087..f097da77cb8 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -58,7 +58,23 @@ def request_handler_factory( authenticated = request.get(KEY_AUTHENTICATED, False) if view.requires_auth and not authenticated: - raise HTTPUnauthorized + # Import here to avoid circular dependency with network.py + from .network import NoURLAvailableError, get_url # noqa: PLC0415 + + try: + url_prefix = get_url(hass, require_current_request=True) + except NoURLAvailableError: + # Omit header to avoid leaking configured URLs + raise HTTPUnauthorized from None + raise HTTPUnauthorized( + # Include resource metadata endpoint for RFC9728 + headers={ + "WWW-Authenticate": ( + f'Bearer resource_metadata="{url_prefix}' + '/.well-known/oauth-protected-resource"' + ) + } + ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 10d379427db..4f36b70a13b 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -428,3 +428,70 @@ async def test_well_known_auth_info( "response_types_supported": ["code"], "service_documentation": "https://developers.home-assistant.io/docs/auth_api", } + + +@pytest.mark.usefixtures("current_request_with_host") # Has example.com host +@pytest.mark.parametrize( + ("config", "expected_response"), + [ + ( + { + "internal_url": "http://192.168.1.100:8123", + # Current request matches external url + "external_url": "https://example.com", + }, + { + "resource": "https://example.com", + "authorization_servers": ["https://example.com"], + "resource_documentation": "https://developers.home-assistant.io/docs/auth_api", + }, + ), + ( + { + # Current request matches internal url + "internal_url": "https://example.com", + "external_url": "https://other.com", + }, + { + "resource": "https://example.com", + "authorization_servers": ["https://example.com"], + "resource_documentation": "https://developers.home-assistant.io/docs/auth_api", + }, + ), + ], + ids=["external_url", "internal_url"], +) +async def test_well_known_protected_resource( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + config: dict[str, str], + expected_response: dict[str, Any], +) -> None: + """Test the well-known OAuth protected resource metadata endpoint per RFC9728.""" + await async_process_ha_core_config(hass, config) + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + resp = await client.get( + "/.well-known/oauth-protected-resource", + ) + assert resp.status == 200 + assert await resp.json() == expected_response + + +@pytest.mark.usefixtures("current_request_with_host") # Has example.com host +async def test_well_known_protected_resource_no_url( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test the protected resource metadata returns 404 when no URL is configured.""" + await async_process_ha_core_config( + hass, + { + "internal_url": "https://other.com", + "external_url": "https://again.com", + }, + ) + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + resp = await client.get( + "/.well-known/oauth-protected-resource", + ) + assert resp.status == 404 diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index 062d3c768a3..539bf56b9e0 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -4,7 +4,7 @@ from decimal import Decimal from http import HTTPStatus import json import math -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch from aiohttp.web_exceptions import ( HTTPBadRequest, @@ -20,6 +20,7 @@ from homeassistant.components.http.view import ( request_handler_factory, ) from homeassistant.exceptions import ServiceNotFound, Unauthorized +from homeassistant.helpers.network import NoURLAvailableError @pytest.fixture @@ -99,3 +100,46 @@ async def test_invalid_handler(mock_request: Mock) -> None: Mock(requires_auth=False), AsyncMock(return_value=["not valid"]), )(mock_request) + + +async def test_requires_auth_includes_www_authenticate( + mock_request: Mock, +) -> None: + """Test that 401 responses include WWW-Authenticate header per RFC9728.""" + mock_request.get = Mock(return_value=False) + with ( + patch( + "homeassistant.helpers.network.get_url", + return_value="https://example.com", + ), + pytest.raises(HTTPUnauthorized) as exc_info, + ): + await request_handler_factory( + mock_request.app[KEY_HASS], + Mock(requires_auth=True), + AsyncMock(), + )(mock_request) + assert exc_info.value.headers["WWW-Authenticate"] == ( + "Bearer resource_metadata=" + '"https://example.com/.well-known/oauth-protected-resource"' + ) + + +async def test_requires_auth_omits_www_authenticate_without_url( + mock_request: Mock, +) -> None: + """Test that 401 responses omit WWW-Authenticate header when no URL is configured.""" + mock_request.get = Mock(return_value=False) + with ( + patch( + "homeassistant.helpers.network.get_url", + side_effect=NoURLAvailableError, + ), + pytest.raises(HTTPUnauthorized) as exc_info, + ): + await request_handler_factory( + mock_request.app[KEY_HASS], + Mock(requires_auth=True), + AsyncMock(), + )(mock_request) + assert "WWW-Authenticate" not in exc_info.value.headers