Add basic provider to test frontend parts
This commit is contained in:
@@ -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")
|
||||
|
||||
_LOGGER.debug("Added OIDC provider")
|
||||
return True
|
||||
41
custom_components/auth_oidc/callback.py
Normal file
41
custom_components/auth_oidc/callback.py
Normal file
@@ -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="<script>if (window.opener) { window.opener.postMessage({type: 'externalCallback'}); } window.close();</script>",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
73
custom_components/auth_oidc/provider.py
Normal file
73
custom_components/auth_oidc/provider.py
Normal file
@@ -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")
|
||||
101
poetry.lock
generated
101
poetry.lock
generated
@@ -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"},
|
||||
|
||||
@@ -11,3 +11,7 @@ python = "3.10.*"
|
||||
[tool.poetry.dev-dependencies]
|
||||
homeassistant = "^2022.11.4"
|
||||
pylint = "^2.15.6"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
autopep8 = "^2.0.0"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user