1
0
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:
Allen Porter
2026-03-24 00:06:48 -07:00
committed by GitHub
parent 8178c8afa0
commit 191f49a326
4 changed files with 156 additions and 2 deletions

View File

@@ -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."""

View File

@@ -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(

View File

@@ -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

View File

@@ -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