Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add myuplink reauth flow #110587

Merged
merged 16 commits into from Feb 17, 2024
7 changes: 6 additions & 1 deletion homeassistant/components/myuplink/__init__.py
Expand Up @@ -6,14 +6,15 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
device_registry as dr,
)

from .api import AsyncConfigEntryAuth
from .const import DOMAIN
from .const import DOMAIN, OAUTH2_SCOPES
from .coordinator import MyUplinkDataCoordinator

PLATFORMS: list[Platform] = [
Expand All @@ -33,6 +34,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, implementation)

if set(config_entry.data["token"]["scope"].split(" ")) != set(OAUTH2_SCOPES):
raise ConfigEntryAuthFailed("Incorrect OAuth2 scope")

auth = AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)

# Setup MyUplinkAPI and coordinator for data fetch
Expand Down
32 changes: 32 additions & 0 deletions homeassistant/components/myuplink/config_flow.py
@@ -1,7 +1,10 @@
"""Config flow for myUplink."""
from collections.abc import Mapping
import logging
from typing import Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow

from .const import DOMAIN, OAUTH2_SCOPES
Expand All @@ -14,6 +17,8 @@ class OAuth2FlowHandler(

DOMAIN = DOMAIN

config_entry_reauth: ConfigEntry | None = None

@property
def logger(self) -> logging.Logger:
"""Return logger."""
Expand All @@ -23,3 +28,30 @@ def logger(self) -> logging.Logger:
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(OAUTH2_SCOPES)}

async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
self.config_entry_reauth = 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: Mapping[str, Any] | None = None
) -> FlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)

return await self.async_step_user()

async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create or update the config entry."""
if self.config_entry_reauth:
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
return self.async_update_reload_and_abort(
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
self.config_entry_reauth,
data=data,
)
return await super().async_oauth_create_entry(data)
2 changes: 1 addition & 1 deletion homeassistant/components/myuplink/const.py
Expand Up @@ -5,4 +5,4 @@
API_ENDPOINT = "https://api.myuplink.com"
OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize"
OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token"
OAUTH2_SCOPES = ["READSYSTEM", "offline_access"]
OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"]
7 changes: 6 additions & 1 deletion homeassistant/components/myuplink/strings.json
Expand Up @@ -3,6 +3,10 @@
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The myUplink integration needs to re-authenticate your account"
}
},
"abort": {
Expand All @@ -12,7 +16,8 @@
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
Expand Down
8 changes: 5 additions & 3 deletions tests/components/myuplink/conftest.py
Expand Up @@ -28,17 +28,17 @@ def mock_expires_at() -> float:


@pytest.fixture
def mock_config_entry(expires_at: int) -> MockConfigEntry:
def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
config_entry = MockConfigEntry(
version=1,
domain=DOMAIN,
title="myUplink test",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "Fake_token",
"scope": "READSYSTEM offline",
"scope": "WRITESYSTEM READSYSTEM offline_access",
MartinHjelmare marked this conversation as resolved.
Show resolved Hide resolved
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"token_type": "Bearer",
Expand All @@ -47,6 +47,8 @@ def mock_config_entry(expires_at: int) -> MockConfigEntry:
},
entry_id="myuplink_test",
)
config_entry.add_to_hass(hass)
return config_entry


@pytest.fixture(autouse=True)
Expand Down
1 change: 0 additions & 1 deletion tests/components/myuplink/snapshots/test_diagnostics.ambr
Expand Up @@ -7,7 +7,6 @@
'access_token': '**REDACTED**',
'expires_in': 86399,
'refresh_token': '**REDACTED**',
'scope': 'READSYSTEM offline',
'token_type': 'Bearer',
}),
}),
Expand Down
126 changes: 103 additions & 23 deletions tests/components/myuplink/test_config_flow.py
Expand Up @@ -2,35 +2,24 @@

from unittest.mock import patch

import pytest

from homeassistant import config_entries
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.myuplink.const import (
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component

CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
from .const import CLIENT_ID

from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator

@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
REDIRECT_URL = "https://example.com/auth/external/callback"
CURRENT_SCOPE = "WRITESYSTEM READSYSTEM offline_access"


async def test_full_flow(
Expand All @@ -42,21 +31,21 @@ async def test_full_flow(
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
"myuplink", context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
"redirect_uri": REDIRECT_URL,
},
)

assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&redirect_uri={REDIRECT_URL}"
f"&state={state}"
"&scope=READSYSTEM+offline_access"
f"&scope={CURRENT_SCOPE.replace(' ', '+')}"
)

client = await hass_client_no_auth()
Expand All @@ -75,9 +64,100 @@ async def test_full_flow(
)

with patch(
"homeassistant.components.myuplink.async_setup_entry", return_value=True
f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True
) as mock_setup:
await hass.config_entries.flow.async_configure(result["flow_id"])

assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1


async def test_flow_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
current_request_with_host: None,
setup_credentials: None,
mock_config_entry: MockConfigEntry,
expires_at: float,
) -> None:
"""Test reauth step."""

OLD_SCOPE = "READSYSTEM offline_access"
OLD_SCOPE_TOKEN = {
"auth_implementation": DOMAIN,
"token": {
"access_token": "Fake_token",
"scope": OLD_SCOPE,
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"token_type": "Bearer",
"expires_at": expires_at,
},
}
assert mock_config_entry.data["token"]["scope"] == CURRENT_SCOPE
assert hass.config_entries.async_update_entry(
mock_config_entry, data=OLD_SCOPE_TOKEN
)
assert mock_config_entry.data["token"]["scope"] == OLD_SCOPE

assert len(hass.config_entries.async_entries(DOMAIN)) == 1

result = await hass.config_entries.flow.async_init(
DOMAIN,
context={
"source": config_entries.SOURCE_REAUTH,
"entry_id": mock_config_entry.entry_id,
},
data=mock_config_entry.data,
)

assert result["step_id"] == "reauth_confirm"

result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["step_id"] == "auth"

state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URL,
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URL}"
f"&state={state}"
f"&scope={CURRENT_SCOPE.replace(' ', '+')}"
)

client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"

aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "updated-refresh-token",
"access_token": "updated-access-token",
"type": "Bearer",
"expires_in": "60",
"scope": CURRENT_SCOPE,
},
)

with patch(
f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()

assert result.get("type") == FlowResultType.ABORT
assert result.get("reason") == "reauth_successful"

assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert mock_config_entry.data["token"]["scope"] == CURRENT_SCOPE
6 changes: 5 additions & 1 deletion tests/components/myuplink/test_diagnostics.py
Expand Up @@ -22,5 +22,9 @@ async def test_diagnostics(
assert await get_diagnostics_for_config_entry(
hass, hass_client, mock_config_entry
) == snapshot(
exclude=paths("config_entry_data.token.expires_at", "myuplink_test.entry_id")
exclude=paths(
"config_entry_data.token.expires_at",
"myuplink_test.entry_id",
"config_entry_data.token.scope",
)
)