diff --git a/custom_components/auth_oidc/__init__.py b/custom_components/auth_oidc/__init__.py index dd67669..79f3165 100644 --- a/custom_components/auth_oidc/__init__.py +++ b/custom_components/auth_oidc/__init__.py @@ -4,28 +4,35 @@ from typing import OrderedDict import voluptuous as vol from homeassistant.core import HomeAssistant -from .example import ExampleAuthProvider - DOMAIN = "auth_oidc" _LOGGER = logging.getLogger(__name__) + +from .provider import OpenIDAuthProvider + CONFIG_SCHEMA = vol.Schema( { - + DOMAIN: vol.Schema( + { + + } + ) }, extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config): - """TODO""" - # Inject Auth-Header provider. + """Add the OIDC Auth Provider to the providers in Home Assistant""" providers = OrderedDict() - provider = ExampleAuthProvider( + + provider = OpenIDAuthProvider( hass, hass.auth._store, config[DOMAIN], ) + providers[(provider.type, provider.id)] = provider providers.update(hass.auth._providers) hass.auth._providers = providers - _LOGGER.debug("Injected example provider") - return True \ No newline at end of file + + _LOGGER.debug("Added OIDC provider") + return True diff --git a/custom_components/auth_oidc/callback.py b/custom_components/auth_oidc/callback.py new file mode 100644 index 0000000..699ac8a --- /dev/null +++ b/custom_components/auth_oidc/callback.py @@ -0,0 +1,41 @@ +from aiohttp import web +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant, callback +import logging + +DATA_VIEW_REGISTERED = "oauth2_view_reg" +AUTH_CALLBACK_PATH = "/auth/oidc/callback" + +_LOGGER = logging.getLogger(__name__) + +@callback +def async_register_view(hass: HomeAssistant) -> None: + """Make sure callback view is registered.""" + if not hass.data.get(DATA_VIEW_REGISTERED, False): + hass.http.register_view(OAuth2AuthorizeCallbackView()) # type: ignore + hass.data[DATA_VIEW_REGISTERED] = True + + +class OAuth2AuthorizeCallbackView(HomeAssistantView): + """OAuth2 Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = "auth:oidc:callback" + + async def get(self, request: web.Request) -> web.Response: + """Receive response.""" + + _LOGGER.debug(request.query) + + hass = request.app["hass"] + flow_mgr = hass.auth.login_flow + + await flow_mgr.async_configure( + flow_id=request.query["flow_id"], user_input=request.query["test"] + ) + + return web.Response( + headers={"content-type": "text/html"}, + text="", + ) \ No newline at end of file diff --git a/custom_components/auth_oidc/example.py b/custom_components/auth_oidc/example.py deleted file mode 100644 index 7707e04..0000000 --- a/custom_components/auth_oidc/example.py +++ /dev/null @@ -1,112 +0,0 @@ -"""OIDC Provider""" -from __future__ import annotations - -from collections.abc import Mapping -import hmac -from typing import Any, cast - -import voluptuous as vol - -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError - -from homeassistant.auth.providers import ( - AUTH_PROVIDERS, - AuthProvider, - LoginFlow, -) - -from homeassistant.auth.models import Credentials, UserMeta - -class InvalidAuthError(HomeAssistantError): - """Raised when submitting invalid authentication.""" - - -@AUTH_PROVIDERS.register("insecure_example_2") -class ExampleAuthProvider(AuthProvider): - """Example auth provider based on hardcoded usernames and passwords.""" - - DEFAULT_TITLE = "OpenID Connect (SSO)" - - @property - def type(self) -> str: - return "auth_oidc" - - @property - def support_mfa(self) -> bool: - """OIDC Authentication Provider does not support MFA in Home Assistant, only external.""" - return False - - async def async_login_flow(self) -> LoginFlow: - """Return a flow to login.""" - return ExampleLoginFlow(self) - - @callback - def async_validate_login(self, input: str) -> None: - """Validate a username and password.""" - - if input is "example": - return - else: - raise InvalidAuthError - - async def async_get_or_create_credentials( - self, flow_result: Mapping[str, str] - ) -> Credentials: - """Get credentials based on the flow result.""" - username = flow_result["input"] - - for credential in await self.async_credentials(): - if credential.data["input"] == username: - return credential - - # Create new credentials. - return self.async_create_credentials({"username": username}) - - async def async_user_meta_for_credentials( - self, credentials: Credentials - ) -> UserMeta: - """Return extra user metadata for credentials. - Will be used to populate info when creating a new user. - """ - username = credentials.data["username"] - name = None - - for user in self.config["users"]: - if user["username"] == username: - name = user.get("name") - break - - return UserMeta(name=name, is_active=True) - - -class ExampleLoginFlow(LoginFlow): - """Handler for the login flow.""" - - async def async_step_init( - self, user_input: dict[str] | None = None - ) -> FlowResult: - """Handle the step of the form.""" - errors = None - - if user_input is not None: - try: - cast(ExampleAuthProvider, self._auth_provider).async_validate_login( - user_input["input"] - ) - except InvalidAuthError: - errors = {"base": "invalid_auth"} - - if not errors: - return await self.async_finish(user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Required("input"): str, - } - ), - errors=errors, - ) \ No newline at end of file diff --git a/custom_components/auth_oidc/provider.py b/custom_components/auth_oidc/provider.py new file mode 100644 index 0000000..02b996f --- /dev/null +++ b/custom_components/auth_oidc/provider.py @@ -0,0 +1,73 @@ +"""OIDC Authentication provider. +Allow access to users based on login with an external OpenID Connect Identity Provider (IdP). +""" +import logging +from secrets import token_hex +from typing import Any, Dict, Optional, cast +from homeassistant.auth.providers import ( + AUTH_PROVIDERS, + AuthProvider, + LoginFlow, +) +from homeassistant.exceptions import HomeAssistantError +import voluptuous as vol +from homeassistant.helpers.network import get_url + +from .callback import async_register_view, AUTH_CALLBACK_PATH + +_LOGGER = logging.getLogger(__name__) + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + +@AUTH_PROVIDERS.register("oidc") +class OpenIDAuthProvider(AuthProvider): + """Allow access to users based on login with an external OpenID Connect Identity Provider (IdP).""" + + DEFAULT_TITLE = "OpenID Connect" + + @property + def type(self) -> str: + return "auth_oidc" + + @property + def support_mfa(self) -> bool: + return False + + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: + """Return a flow to login.""" + + async_register_view(self.hass) + return OpenIdLoginFlow(self) + + +class OpenIdLoginFlow(LoginFlow): + """Handler for the login flow.""" + + async def async_step_init( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Handle the step of the form.""" + return await self.async_step_authenticate() + + def redirect_uri(self) -> str: + """Return the redirect uri.""" + return f"{get_url(self.hass, require_current_request=True)}{AUTH_CALLBACK_PATH}?test=value&flow_id={self.flow_id}" + + async def async_step_authenticate( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Authenticate user using external step.""" + + if user_input: + self.external_data = str(user_input) + return self.async_external_step_done(next_step_id="authorize") + + return self.async_external_step(step_id="authenticate", url=self.redirect_uri()) + + async def async_step_authorize( + self, user_input: Optional[Dict[str, str]] = None + ) -> Dict[str, Any]: + """Authorize user received from external step.""" + _LOGGER.log(user_input) + return self.async_abort(reason="invalid_auth") \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 80b205e..e986dad 100644 --- a/poetry.lock +++ b/poetry.lock @@ -99,6 +99,18 @@ docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] tests-no-zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] +[[package]] +name = "autopep8" +version = "2.0.0" +description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pycodestyle = ">=2.9.1" +tomli = "*" + [[package]] name = "awesomeversion" version = "22.9.0" @@ -233,6 +245,21 @@ python-versions = ">=3.7" [package.extras] graph = ["objgraph (>=1.7.2)"] +[[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + [[package]] name = "frozenlist" version = "1.3.3" @@ -448,6 +475,22 @@ python-versions = ">=3.7" docs = ["furo (>=2022.9.29)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.4)"] test = ["appdirs (==1.4.4)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] +[[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pycodestyle" +version = "2.10.0" +description = "Python style guide checker" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "pycparser" version = "2.21" @@ -534,6 +577,25 @@ python-versions = ">=3.6" [package.dependencies] pyobjc-core = ">=8.5.1" +[[package]] +name = "python-jose" +version = "3.1.0" +description = "JOSE implementation in Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ecdsa = "<1.0" +pyasn1 = "*" +rsa = "*" +six = "<2.0" + +[package.extras] +cryptography = ["cryptography"] +pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] + [[package]] name = "python-slugify" version = "4.0.1" @@ -596,11 +658,22 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} [package.extras] idna2008 = ["idna"] +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +category = "main" +optional = false +python-versions = ">=3.6,<4" + +[package.dependencies] +pyasn1 = ">=0.1.3" + [[package]] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" @@ -699,7 +772,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "3.10.*" -content-hash = "048564927d5a082cb05f5b27dfa23d3b48d5642d518a46a75f10a7fa08ac35bf" +content-hash = "41e3dcf2a24691a6a77663f06452932a9d6f549323a315985763991509196145" [metadata.files] aiohttp = [ @@ -804,6 +877,10 @@ attrs = [ {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] +autopep8 = [ + {file = "autopep8-2.0.0-py2.py3-none-any.whl", hash = "sha256:ad924b42c2e27a1ac58e432166cc4588f5b80747de02d0d35b1ecbd3e7d57207"}, + {file = "autopep8-2.0.0.tar.gz", hash = "sha256:8b1659c7f003e693199f52caffdc06585bb0716900bbc6a7442fd931d658c077"}, +] awesomeversion = [ {file = "awesomeversion-22.9.0-py3-none-any.whl", hash = "sha256:f4716e1e65ea1194be03f312f2b2643a8b76326c59538ddc5353642616ead82a"}, {file = "awesomeversion-22.9.0.tar.gz", hash = "sha256:2f4190d333e81e10b2a4e156150ddb3596f5f11da67e9d51ba39057aa7a17f7e"}, @@ -990,6 +1067,10 @@ dill = [ {file = "dill-0.3.6-py3-none-any.whl", hash = "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0"}, {file = "dill-0.3.6.tar.gz", hash = "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"}, ] +ecdsa = [ + {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, + {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, +] frozenlist = [ {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, @@ -1339,6 +1420,14 @@ platformdirs = [ {file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"}, {file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"}, ] +pyasn1 = [ + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] +pycodestyle = [ + {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, + {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, +] pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, @@ -1387,6 +1476,10 @@ pyobjc-framework-libdispatch = [ {file = "pyobjc_framework_libdispatch-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:73226e224436eb6383e7a8a811c90ed597995adb155b4f46d727881a383ac550"}, {file = "pyobjc_framework_libdispatch-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d115355ce446fc073c75cedfd7ab0a13958adda8e3a3b1e421e1f1e5f65640da"}, ] +python-jose = [ + {file = "python-jose-3.1.0.tar.gz", hash = "sha256:8484b7fdb6962e9d242cce7680469ecf92bda95d10bbcbbeb560cacdff3abfce"}, + {file = "python_jose-3.1.0-py2.py3-none-any.whl", hash = "sha256:1ac4caf4bfebd5a70cf5bd82702ed850db69b0b6e1d0ae7368e5f99ac01c9571"}, +] python-slugify = [ {file = "python-slugify-4.0.1.tar.gz", hash = "sha256:69a517766e00c1268e5bbfc0d010a0a8508de0b18d30ad5a1ff357f8ae724270"}, ] @@ -1444,6 +1537,10 @@ rfc3986 = [ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, ] +rsa = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, diff --git a/pyproject.toml b/pyproject.toml index 5fecccb..7fa7a00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,8 @@ python = "3.10.*" [tool.poetry.dev-dependencies] homeassistant = "^2022.11.4" -pylint = "^2.15.6" \ No newline at end of file +pylint = "^2.15.6" + +[tool.poetry.group.dev.dependencies] +autopep8 = "^2.0.0" +