Skip to content

Commit

Permalink
Support for config flow
Browse files Browse the repository at this point in the history
+ Added support for simple config flow allowing the integration to be
  configured either via YAML or UI
+ `test/conftest.py`: added custom fixture inheriting from
  `enable_custom_integrations` one from Pytest HASS package so that
  custom integrations are initialized during tests
* `tox.ini`: Explicitly added `aiohttp_cors` package required for `http`
  component being dependency during tests
* Updated comments
* `manifest.json`: Updated `integration_type` to `service` otherwise it
  won't get properly added via UI
  • Loading branch information
hostcc committed Sep 9, 2023
1 parent f1f7e4f commit ddb5a02
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 12 deletions.
48 changes: 41 additions & 7 deletions custom_components/oidc_userinfo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""
tbd
Custom HASS component to provide minimal OIDC endpoint for information about
current user.
"""
from __future__ import annotations
from contextlib import suppress
Expand All @@ -9,44 +10,77 @@
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.network import get_url, NoURLAvailableError
from homeassistant.helpers import config_validation as cv
from homeassistant.config_entries import ConfigEntry

# Required for any integration
CONFIG_SCHEMA = None
from .const import DOMAIN

# Required for any integration, provide empty schema since component doesn't
# use it
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

async def async_setup(hass: HomeAssistant, _config: ConfigType) -> bool:
""" tbd """

async def async_setup(
hass: HomeAssistant, _config: ConfigType
) -> bool:
"""
Register the HTTP view when component is configured manually via YAML.
"""
hass.http.register_view(CurrentUserView)

return True


async def async_setup_entry(
hass: HomeAssistant, _config_entry: ConfigEntry
) -> bool:
"""
Register the HTTP view when component is configured via UI.
"""
# Invoke `async_setup` to avoid code duplication
return await async_setup(hass, None)


class CurrentUserView(HomeAssistantView):
""" tbd """
"""
HTTP view to provide minimal OIDC endpoint for current user.
"""

url = "/auth/userinfo"
name = "api:auth:userinfo"
requires_auth = True

@callback
async def get(self, request):
""" tbd """
"""
Handles GET requests.
"""
user = request["hass_user"]
hass = request.app['hass']

# Default host if more specific one is not available, will be used in
# user's email (HomeAssistant doesn't have such property for users)
hass_host = 'homeassistant.local'
# Attempt to determine the host from HomeAssistant URL
with suppress(NoURLAvailableError):
hass_host = yarl.URL(get_url(hass, allow_ip=True)).host

# Determine user name from authentication provider(s)
user_name = user.name
for cred in user.credentials:
provider = hass.auth.get_auth_provider(
cred.auth_provider_type, cred.auth_provider_id
)
user_meta = await provider.async_user_meta_for_credentials(cred)
user_name = user_meta.name
# Exit on first found
break

# Provide minimal set of OIDC claims UserInfo,
# https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse.
# Since the authentication token in HASS doesn't have a notion of
# claims the set of those is constructed statically (as if
# 'profile+email' has been requested)
return self.json({
'name': user.name,
'email': f'{user_name}@{hass_host}',
Expand Down
29 changes: 29 additions & 0 deletions custom_components/oidc_userinfo/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""
ConfigFlow support for the custom component.
"""
from __future__ import annotations
from typing import Any

from homeassistant import config_entries
from homeassistant.data_entry_flow import FlowResult

from .const import DOMAIN, TITLE


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""
Handles the config flow for the integration.
"""
VERSION = 1

async def async_step_user(
self, _user_input: dict[str, Any] | None = None
) -> FlowResult:
"""
Handles adding single entry upon user confirmation.
"""
# Only single entry allowed
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")

return self.async_create_entry(title=TITLE, data={})
6 changes: 6 additions & 0 deletions custom_components/oidc_userinfo/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Constants for custom integration.
"""

DOMAIN = 'oidc_userinfo'
TITLE = 'OIDC UserInfo endpoint'
3 changes: 2 additions & 1 deletion custom_components/oidc_userinfo/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
"codeowners": [
"@hostcc"
],
"config_flow": true,
"dependencies": [
"http"
],
"documentation": "https://github.com/hostcc/hass-oidc-userinfo/blob/main/README.md",
"integration_type": "helper",
"integration_type": "service",
"iot_class": "calculated",
"issue_tracker": "https://github.com/hostcc/hass-oidc-userinfo/issues",
"version": "0.0.0"
Expand Down
12 changes: 12 additions & 0 deletions custom_components/oidc_userinfo/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"config": {
"step": {
"user": {
"description": "Do you want to add OIDC UserInfo to Home Assistant?"
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
}
}
}
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
Pytest configuration and fixtures
"""
import pytest


@pytest.fixture(autouse=True)
# pylint: disable=unused-argument
def auto_enable_custom_integrations(enable_custom_integrations):
"""
Automatically uses `enable_custom_integrations` Homeassistant fixture,
since it is required for custom integrations to be loaded during tests.
"""
yield
25 changes: 21 additions & 4 deletions tests/test_auth_user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
""" tbd """
"""
Tests for HTTP view for current user information
"""
from unittest.mock import Mock
import json
from http import HTTPStatus
Expand All @@ -12,15 +14,23 @@


class MockWebRequest(Mock, dict):
""" tbd """
"""
Mocked aiohttp.web.Request
"""
def __init__(self, *args, **kwargs):
# The mocked class inherits from `dict` and `Mock` so call both
# constructors
dict.__init__(self, *args, **kwargs)
Mock.__init__(self, *args, **kwargs)
# `match_info` member is used by HASS so initialize it with sane
# defaults
self.match_info = {}


async def test_auth_user_not_authenticated(hass):
""" tbd """
"""
Tests for HTTP Unathorized when view is called by non-authenticated client.
"""
request = MockWebRequest(
{'ha_authenticated': False},
path='/dummy',
Expand All @@ -36,9 +46,13 @@ async def test_auth_user_not_authenticated(hass):


async def test_auth_user(hass, hass_admin_credential, hass_admin_user):
""" tbd """
"""
Tests for correct response from the view when client is authenticated
"""
# Link admin user and its credentials (both mocked) together
await hass.auth.async_link_user(hass_admin_user, hass_admin_credential)
request = MockWebRequest(
# Pretend admin user has been authenticated
{
'hass_user': hass_admin_user,
'ha_authenticated': True,
Expand All @@ -55,6 +69,7 @@ async def test_auth_user(hass, hass_admin_credential, hass_admin_user):
)(request)

assert response.status == HTTPStatus.OK
# Retrieve user name for admin credentials from the authentication provider
user_name = (
await hass.auth.get_auth_provider(
hass_admin_credential.auth_provider_type,
Expand All @@ -65,6 +80,8 @@ async def test_auth_user(hass, hass_admin_credential, hass_admin_user):
).name
response_obj = json.loads(response.text)
assert response_obj == {
# The domain should fallback to `homeassistant.local` since no real
# networking is available
'email': f'{user_name}@homeassistant.local',
'sub': hass_admin_user.id,
'name': hass_admin_user.name,
Expand Down
32 changes: 32 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""
Tests config flow for the custom component.
"""
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.config_entries import ConfigEntry

from custom_components.oidc_userinfo.const import DOMAIN


async def test_config_flow(hass):
"""
Tests config flow with no options.
"""
# Initial step
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
)

# Verify it results in creating entity of proper type/domain
assert result['type'] == FlowResultType.CREATE_ENTRY
assert isinstance(result['result'], ConfigEntry)
assert result['result'].domain == DOMAIN

# Attemting to instantiate another entry should be aborted
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
)

assert result['type'] == FlowResultType.ABORT
assert result['reason'] == 'single_instance_allowed'
2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ deps =
flake8==6.1.0
pylint==2.17.5
pytest==7.3.1
# Required for `http` component being dependency
aiohttp_cors==0.7.0

allowlist_externals =
cat
Expand Down

0 comments on commit ddb5a02

Please sign in to comment.