mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 08:26:41 +01:00
Add RFC9728 OAuth2 Protected Resource metadata endpoint (#166213)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user