Prefer external URL in WWW-Authenticate header for RFC 9728 (#169658)

This commit is contained in:
Allen Porter
2026-05-27 12:57:02 +02:00
committed by GitHub
parent efe0000fbe
commit a6fcc9f3ff
2 changed files with 83 additions and 2 deletions
+6 -2
View File
@@ -59,13 +59,17 @@ def request_handler_factory(
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
# Get the current request header to include as resource metadata
# endpoint for RFC9728. We currently prefer external since this
# is likely most used by remote OAuth clients
try:
url_prefix = get_url(hass, require_current_request=True)
url_prefix = get_url(
hass, require_current_request=True, prefer_external=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}'
+77
View File
@@ -1,11 +1,13 @@
"""Tests for Home Assistant View."""
from collections.abc import Generator
from decimal import Decimal
from http import HTTPStatus
import json
import math
from unittest.mock import AsyncMock, Mock, patch
from aiohttp import hdrs
from aiohttp.web_exceptions import (
HTTPBadRequest,
HTTPInternalServerError,
@@ -15,10 +17,12 @@ import pytest
import voluptuous as vol
from homeassistant.components.http import KEY_HASS
from homeassistant.components.http.request_context import current_request
from homeassistant.components.http.view import (
HomeAssistantView,
request_handler_factory,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceNotFound, Unauthorized
from homeassistant.helpers.network import NoURLAvailableError
@@ -143,3 +147,76 @@ async def test_requires_auth_omits_www_authenticate_without_url(
AsyncMock(),
)(mock_request)
assert "WWW-Authenticate" not in exc_info.value.headers
@pytest.fixture
def mock_current_request(
mock_request: Mock, request_host: str, hass: HomeAssistant
) -> Generator[Mock]:
"""Set the current request context."""
mock_request.get = Mock(return_value=False)
mock_request.headers = {hdrs.HOST: request_host}
mock_request.app = {KEY_HASS: hass}
token = current_request.set(mock_request)
yield mock_request
current_request.reset(token)
@pytest.mark.parametrize(
("internal_url", "external_url", "request_host", "expected_url"),
[
# Match either internal or external
("https://foo.com", "https://example.com", "foo.com:18123", "https://foo.com"),
("https://example.com", "https://foo.com", "foo.com:18123", "https://foo.com"),
# Requests have a port and match external url
# Note: We currently do not fully properly handle port matching for
# internal urls. The tests here work because of prefer_external=True. We
# can improve get_url so that additional cases where the internal url
# have the same hostname work in future:
# - Match request to internal url when external url has a port
# - Match request to external url when internal url has a port
(
"https://foo.com",
"https://foo.com:18123",
"foo.com:18123",
"https://foo.com:18123",
),
("https://foo.com:18123", "https://foo.com", "foo.com", "https://foo.com"),
(
"http://192.168.1.2:8123",
"https://foo.com:18123",
"192.168.1.2:8123",
"http://192.168.1.2:8123",
),
],
ids=[
"request_host_matches_internal",
"request_host_matches_external",
"internal_no_port_request_external",
"internal_port_request_external",
"request_internal_distinct_host",
],
)
async def test_requires_auth_www_authenticate_prefer_external(
mock_current_request: Mock,
hass: HomeAssistant,
internal_url: str,
external_url: str,
expected_url: str,
) -> None:
"""Test that 401 responses include WWW-Authenticate header matching the requested URL."""
hass.config.internal_url = internal_url
hass.config.external_url = external_url
with pytest.raises(HTTPUnauthorized) as exc_info:
await request_handler_factory(
hass,
Mock(requires_auth=True),
AsyncMock(),
)(mock_current_request)
assert exc_info.value.headers["WWW-Authenticate"] == (
"Bearer resource_metadata="
f'"{expected_url}/.well-known/oauth-protected-resource"'
)