diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index a314a64adf0c..cc7590241863 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -291,6 +291,14 @@ class TokenView(HomeAssistantView): hass = request.app[KEY_HASS] data = cast(MultiDictProxy[str], await request.post()) + # RFC 6749 3.2: parameters must not be included more than once. + if len(data) != len(set(data)) or any( + not isinstance(value, str) for value in data.values() + ): + return self.json( + {"error": "invalid_request"}, status_code=HTTPStatus.BAD_REQUEST + ) + grant_type = data.get("grant_type") # IndieAuth 6.3.5 diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index b5134ce5205c..c73267b5f49e 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -365,6 +365,7 @@ class LoginFlowIndexView(LoginFlowBaseView): r"^[A-Za-z0-9_-]{43}\Z" ), probatio.Optional("code_challenge_method"): str, + probatio.Optional("response_type"): str, probatio.Optional( "type", default="authorize" ): str, # not used, kept for backwards compatibility @@ -380,6 +381,11 @@ class LoginFlowIndexView(LoginFlowBaseView): if not indieauth.verify_client_id(client_id): return self.json_message("Invalid client id", HTTPStatus.BAD_REQUEST) + if data.get("response_type", "code") != "code": + return self.json_message( + "Response type not supported", HTTPStatus.BAD_REQUEST + ) + code_challenge = data.get("code_challenge") code_challenge_method = data.get("code_challenge_method") if code_challenge_method is not None and not code_challenge: diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 142d7a57827e..c2245c202453 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -6,8 +6,10 @@ import logging from typing import Any from unittest.mock import patch +from aiohttp import FormData from aiohttp.test_utils import TestClient from freezegun.api import FrozenDateTimeFactory +from multidict import MultiDict import pytest from homeassistant.auth import InvalidAuthError @@ -800,6 +802,63 @@ async def _async_login_for_code( return step["result"] +@pytest.mark.parametrize( + "parameter", ["client_id", "grant_type", "code", "redirect_uri", "code_verifier"] +) +async def test_pkce_token_request_rejects_duplicate_parameters( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + parameter: str, +) -> None: + """Test ambiguous token parameters are rejected without consuming the code.""" + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + code = await _async_login_for_code(client, RFC7636_CHALLENGE) + token_data = MultiDict( + { + "client_id": CLIENT_ID, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": CLIENT_REDIRECT_URI, + "code_verifier": RFC7636_VERIFIER, + } + ) + token_data.add(parameter, "other") + + resp = await client.post("/auth/token", data=token_data) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert (await resp.json())["error"] == "invalid_request" + + resp = await client.post("/auth/token", data=dict(token_data)) + assert resp.status == HTTPStatus.OK + + +async def test_pkce_token_request_rejects_file_verifier( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator +) -> None: + """Test a multipart file cannot be used as a code verifier.""" + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + code = await _async_login_for_code(client, RFC7636_CHALLENGE) + token_data = { + "client_id": CLIENT_ID, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": CLIENT_REDIRECT_URI, + } + form_data = FormData(token_data) + form_data.add_field("code_verifier", RFC7636_VERIFIER.encode(), filename="verifier") + + resp = await client.post("/auth/token", data=form_data) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert (await resp.json())["error"] == "invalid_request" + + resp = await client.post( + "/auth/token", data={**token_data, "code_verifier": RFC7636_VERIFIER} + ) + assert resp.status == HTTPStatus.OK + + async def test_auth_code_pkce_success( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator ) -> None: diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index 96cefdf7910f..609f95c67924 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -245,8 +245,14 @@ async def test_invalid_redirect_uri( assert data["message"] == "Invalid redirect URI" +@pytest.mark.parametrize( + "authorization_data", + [{}, {"response_type": "code"}], +) async def test_login_exist_user( - hass: HomeAssistant, aiohttp_client: ClientSessionGenerator + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + authorization_data: dict[str, str], ) -> None: """Test logging in with exist user.""" client = await async_setup_auth(hass, aiohttp_client, setup_api=True) @@ -261,6 +267,7 @@ async def test_login_exist_user( "client_id": CLIENT_ID, "handler": ["insecure_example", None], "redirect_uri": CLIENT_REDIRECT_URI, + **authorization_data, }, ) assert resp.status == HTTPStatus.OK @@ -538,6 +545,12 @@ async def test_well_known_protected_resource_no_url( }, "Message format incorrect", ), + ( + { + "response_type": "token", + }, + "Response type not supported", + ), ], ids=[ "method_without_challenge", @@ -545,6 +558,7 @@ async def test_well_known_protected_resource_no_url( "unsupported_plain_method", "challenge_too_short", "challenge_padded", + "unsupported_response_type", ], ) async def test_login_flow_pkce_validation(