Add re-authentication to Jellyfin (#97442)

This commit is contained in:
Jan Stienstra 2023-10-25 14:02:30 +02:00 committed by GitHub
parent edc9aba722
commit 2c46a975fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 328 additions and 42 deletions

View File

@ -3,11 +3,11 @@ from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input
from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS
from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS
from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator
from .models import JellyfinData
@ -30,9 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
user_id, connect_result = await validate_input(hass, dict(entry.data), client)
except CannotConnect as ex:
raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex
except InvalidAuth:
LOGGER.error("Failed to login to Jellyfin server")
return False
except InvalidAuth as ex:
raise ConfigEntryAuthFailed(ex) from ex
server_info: dict[str, Any] = connect_result["Servers"][0]

View File

@ -1,6 +1,7 @@
"""Config flow for the Jellyfin integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@ -24,6 +25,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_PASSWORD, default=""): str,
}
)
def _generate_client_device_id() -> str:
"""Generate a random UUID4 string to identify ourselves."""
@ -38,6 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the Jellyfin config flow."""
self.client_device_id: str | None = None
self.entry: config_entries.ConfigEntry | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@ -83,3 +91,41 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
if user_input is not None:
assert self.entry is not None
new_input = self.entry.data | user_input
if self.client_device_id is None:
self.client_device_id = _generate_client_device_id()
client = create_client(device_id=self.client_device_id)
try:
await validate_input(self.hass, new_input, client)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception as ex: # pylint: disable=broad-except
errors["base"] = "unknown"
_LOGGER.exception(ex)
else:
self.hass.config_entries.async_update_entry(self.entry, data=new_input)
await self.hass.config_entries.async_reload(self.entry.entry_id)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors
)

View File

@ -1,6 +1,13 @@
{
"config": {
"step": {
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Jellyfin integration needs to re-authenticate your account",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
},
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]",
@ -16,7 +23,8 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
}
}

View File

@ -2,6 +2,18 @@
from typing import Final
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
TEST_URL: Final = "https://example.com"
TEST_USERNAME: Final = "test-username"
TEST_PASSWORD: Final = "test-password"
USER_INPUT: Final = {
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
}
REAUTH_INPUT: Final = {
CONF_PASSWORD: TEST_PASSWORD,
}

View File

@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from . import async_load_json_fixture
from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME
from .const import REAUTH_INPUT, TEST_PASSWORD, TEST_URL, TEST_USERNAME, USER_INPUT
from tests.common import MockConfigEntry
@ -44,11 +44,7 @@ async def test_form(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
user_input=USER_INPUT,
)
await hass.async_block_till_done()
@ -73,7 +69,7 @@ async def test_form_cannot_connect(
mock_client: MagicMock,
mock_client_device_id: MagicMock,
) -> None:
"""Test we handle an unreachable server."""
"""Test configuration with an unreachable server."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -86,11 +82,7 @@ async def test_form_cannot_connect(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
user_input=USER_INPUT,
)
await hass.async_block_till_done()
@ -106,7 +98,7 @@ async def test_form_invalid_auth(
mock_client: MagicMock,
mock_client_device_id: MagicMock,
) -> None:
"""Test that we can handle invalid credentials."""
"""Test configuration with invalid credentials."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -119,11 +111,7 @@ async def test_form_invalid_auth(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
user_input=USER_INPUT,
)
await hass.async_block_till_done()
@ -137,7 +125,7 @@ async def test_form_invalid_auth(
async def test_form_exception(
hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock
) -> None:
"""Test we handle an unexpected exception during server setup."""
"""Test configuration with an unexpected exception."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -148,11 +136,7 @@ async def test_form_exception(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
user_input=USER_INPUT,
)
await hass.async_block_till_done()
@ -168,7 +152,7 @@ async def test_form_persists_device_id_on_error(
mock_client: MagicMock,
mock_client_device_id: MagicMock,
) -> None:
"""Test that we can handle invalid credentials."""
"""Test persisting the device id on error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -182,11 +166,7 @@ async def test_form_persists_device_id_on_error(
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
user_input=USER_INPUT,
)
await hass.async_block_till_done()
@ -200,11 +180,7 @@ async def test_form_persists_device_id_on_error(
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_URL: TEST_URL,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
user_input=USER_INPUT,
)
await hass.async_block_till_done()
@ -216,3 +192,244 @@ async def test_form_persists_device_id_on_error(
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
}
async def test_reauth(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_jellyfin: MagicMock,
mock_client: MagicMock,
) -> None:
"""Test a reauth flow."""
# Force a reauth
mock_client.auth.connect_to_address.return_value = await async_load_json_fixture(
hass,
"auth-connect-address.json",
)
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login-failure.json",
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
data=USER_INPUT,
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
# Complete the reauth
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login.json",
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == data_entry_flow.FlowResultType.ABORT
assert result2["reason"] == "reauth_successful"
async def test_reauth_cannot_connect(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_jellyfin: MagicMock,
mock_client: MagicMock,
) -> None:
"""Test an unreachable server during a reauth flow."""
# Force a reauth
mock_client.auth.connect_to_address.return_value = await async_load_json_fixture(
hass,
"auth-connect-address.json",
)
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login-failure.json",
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
data=USER_INPUT,
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
# Perform reauth with unreachable server
mock_client.auth.connect_to_address.return_value = await async_load_json_fixture(
hass, "auth-connect-address-failure.json"
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
assert len(mock_client.auth.connect_to_address.mock_calls) == 1
# Complete reauth with reachable server
mock_client.auth.connect_to_address.return_value = await async_load_json_fixture(
hass, "auth-connect-address.json"
)
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login.json",
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
assert result3["type"] == data_entry_flow.FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
async def test_reauth_invalid(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_jellyfin: MagicMock,
mock_client: MagicMock,
) -> None:
"""Test invalid credentials during a reauth flow."""
# Force a reauth
mock_client.auth.connect_to_address.return_value = await async_load_json_fixture(
hass,
"auth-connect-address.json",
)
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login-failure.json",
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
data=USER_INPUT,
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
# Perform reauth with invalid credentials
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
assert len(mock_client.auth.connect_to_address.mock_calls) == 1
assert len(mock_client.auth.login.mock_calls) == 1
# Complete reauth with valid credentials
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login.json",
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
assert result3["type"] == data_entry_flow.FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
async def test_reauth_exception(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_jellyfin: MagicMock,
mock_client: MagicMock,
) -> None:
"""Test an unexpected exception during a reauth flow."""
# Force a reauth
mock_client.auth.connect_to_address.return_value = await async_load_json_fixture(
hass,
"auth-connect-address.json",
)
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login-failure.json",
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
data=USER_INPUT,
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {}
# Perform a reauth with an unknown exception
mock_client.auth.connect_to_address.side_effect = Exception("UnknownException")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
await hass.async_block_till_done()
assert result2["type"] == "form"
assert result2["errors"] == {"base": "unknown"}
assert len(mock_client.auth.connect_to_address.mock_calls) == 1
# Complete the reauth without an exception
mock_client.auth.login.return_value = await async_load_json_fixture(
hass,
"auth-login.json",
)
mock_client.auth.connect_to_address.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=REAUTH_INPUT,
)
assert result3["type"] == data_entry_flow.FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"

View File

@ -2,7 +2,7 @@
from unittest.mock import MagicMock
from homeassistant.components.jellyfin.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
@ -67,6 +67,10 @@ async def test_invalid_auth(
mock_config_entry.add_to_hass(hass)
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["source"] == SOURCE_REAUTH
async def test_load_unload_config_entry(
hass: HomeAssistant,