45 - Implement config flow for UI configuration (#123)
This commit is contained in:
@@ -4,6 +4,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import OrderedDict
|
from typing import OrderedDict
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
# Import and re-export config schema explictly
|
# Import and re-export config schema explictly
|
||||||
@@ -43,9 +44,77 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config):
|
async def async_setup(hass: HomeAssistant, config):
|
||||||
"""Add the OIDC Auth Provider to the providers in Home Assistant"""
|
"""Add the OIDC Auth Provider to the providers in Home Assistant (YAML config)."""
|
||||||
|
if DOMAIN not in config:
|
||||||
|
return True
|
||||||
|
|
||||||
my_config = config[DOMAIN]
|
my_config = config[DOMAIN]
|
||||||
|
|
||||||
|
# Store YAML config for later access by config flow
|
||||||
|
if DOMAIN not in hass.data:
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
hass.data[DOMAIN]["yaml_config"] = my_config
|
||||||
|
|
||||||
|
await _setup_oidc_provider(
|
||||||
|
hass, my_config, config[DOMAIN].get(DISPLAY_NAME, DEFAULT_TITLE)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
|
"""Set up OIDC Authentication from a config entry."""
|
||||||
|
# Convert config entry data to the format expected by the existing setup
|
||||||
|
config_data = entry.data.copy()
|
||||||
|
|
||||||
|
# Convert config entry format to internal format
|
||||||
|
my_config = _convert_config_entry_to_internal_format(config_data)
|
||||||
|
|
||||||
|
# Get display name from config entry
|
||||||
|
display_name = config_data.get("display_name", DEFAULT_TITLE)
|
||||||
|
|
||||||
|
await _setup_oidc_provider(hass, my_config, display_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry):
|
||||||
|
"""Unload a config entry."""
|
||||||
|
# OIDC auth providers cannot be easily unloaded as they are integrated
|
||||||
|
# into Home Assistant's auth system. A restart is required.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_config_entry_to_internal_format(config_data: dict) -> dict:
|
||||||
|
"""Convert config entry data to internal configuration format."""
|
||||||
|
my_config = {}
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
my_config[CLIENT_ID] = config_data["client_id"]
|
||||||
|
my_config[DISCOVERY_URL] = config_data["discovery_url"]
|
||||||
|
|
||||||
|
# Optional fields
|
||||||
|
if "client_secret" in config_data:
|
||||||
|
my_config[CLIENT_SECRET] = config_data["client_secret"]
|
||||||
|
|
||||||
|
if "display_name" in config_data:
|
||||||
|
my_config[DISPLAY_NAME] = config_data["display_name"]
|
||||||
|
|
||||||
|
# Features configuration
|
||||||
|
if "features" in config_data:
|
||||||
|
my_config[FEATURES] = config_data["features"]
|
||||||
|
|
||||||
|
# Claims configuration
|
||||||
|
if "claims" in config_data:
|
||||||
|
my_config[CLAIMS] = config_data["claims"]
|
||||||
|
|
||||||
|
# Roles configuration
|
||||||
|
if "roles" in config_data:
|
||||||
|
my_config[ROLES] = config_data["roles"]
|
||||||
|
|
||||||
|
return my_config
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_oidc_provider(hass: HomeAssistant, my_config: dict, display_name: str):
|
||||||
|
"""Set up the OIDC provider with the given configuration."""
|
||||||
providers = OrderedDict()
|
providers = OrderedDict()
|
||||||
|
|
||||||
# Use private APIs until there is a real auth platform
|
# Use private APIs until there is a real auth platform
|
||||||
@@ -80,7 +149,7 @@ async def async_setup(hass: HomeAssistant, config):
|
|||||||
scope += " ".join(additional_scopes)
|
scope += " ".join(additional_scopes)
|
||||||
|
|
||||||
# Create the OIDC client
|
# Create the OIDC client
|
||||||
oidc_client = oidc_client = OIDCClient(
|
oidc_client = OIDCClient(
|
||||||
hass=hass,
|
hass=hass,
|
||||||
discovery_url=my_config.get(DISCOVERY_URL),
|
discovery_url=my_config.get(DISCOVERY_URL),
|
||||||
client_id=my_config.get(CLIENT_ID),
|
client_id=my_config.get(CLIENT_ID),
|
||||||
@@ -97,7 +166,7 @@ async def async_setup(hass: HomeAssistant, config):
|
|||||||
is_frontend_injection_enabled = (
|
is_frontend_injection_enabled = (
|
||||||
features_config.get(FEATURES_DISABLE_FRONTEND_INJECTION, False) is False
|
features_config.get(FEATURES_DISABLE_FRONTEND_INJECTION, False) is False
|
||||||
)
|
)
|
||||||
name = config[DOMAIN].get(DISPLAY_NAME, DEFAULT_TITLE)
|
name = display_name
|
||||||
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
|
name = re.sub(r"[^A-Za-z0-9 _\-\(\)]", "", name)
|
||||||
|
|
||||||
force_https = features_config.get(FEATURES_FORCE_HTTPS, False)
|
force_https = features_config.get(FEATURES_FORCE_HTTPS, False)
|
||||||
|
|||||||
806
custom_components/auth_oidc/config_flow.py
Normal file
806
custom_components/auth_oidc/config_flow.py
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
"""Config flow for OIDC Authentication integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
|
||||||
|
from .config import DOMAIN
|
||||||
|
from .oidc_client import OIDCClient, OIDCDiscoveryInvalid, OIDCJWKSInvalid
|
||||||
|
from .provider_catalog import (
|
||||||
|
OIDC_PROVIDERS,
|
||||||
|
get_provider_name,
|
||||||
|
get_provider_docs_url,
|
||||||
|
)
|
||||||
|
from .validation import (
|
||||||
|
validate_discovery_url,
|
||||||
|
sanitize_client_secret,
|
||||||
|
validate_client_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration field names
|
||||||
|
CONF_PROVIDER = "provider"
|
||||||
|
CONF_CLIENT_ID = "client_id"
|
||||||
|
CONF_CLIENT_SECRET = "client_secret"
|
||||||
|
CONF_DISCOVERY_URL = "discovery_url"
|
||||||
|
CONF_ENABLE_GROUPS = "enable_groups"
|
||||||
|
CONF_ADMIN_GROUP = "admin_group"
|
||||||
|
CONF_USER_GROUP = "user_group"
|
||||||
|
CONF_ENABLE_USER_LINKING = "enable_user_linking"
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_GROUP = "admins"
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
DISCOVERY_CACHE_TTL = 300 # 5 minutes
|
||||||
|
MAX_CACHE_SIZE = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FlowState:
|
||||||
|
"""State tracking for the configuration flow."""
|
||||||
|
|
||||||
|
provider: str | None = None
|
||||||
|
discovery_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClientConfig:
|
||||||
|
"""Client configuration settings."""
|
||||||
|
|
||||||
|
client_id: str | None = None
|
||||||
|
client_secret: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FeatureConfig:
|
||||||
|
"""Feature configuration settings."""
|
||||||
|
|
||||||
|
enable_groups: bool = False
|
||||||
|
admin_group: str = DEFAULT_ADMIN_GROUP
|
||||||
|
user_group: str | None = None
|
||||||
|
enable_user_linking: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handle a config flow for OIDC Authentication."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def is_matching(self, other_flow):
|
||||||
|
"""Check if this flow is the same as another flow."""
|
||||||
|
self_state = getattr(self, "_flow_state", None)
|
||||||
|
other_state = getattr(other_flow, "_flow_state", None)
|
||||||
|
|
||||||
|
if not self_state or not other_state:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self_discovery_url = self_state.discovery_url
|
||||||
|
other_discovery_url = other_state.discovery_url
|
||||||
|
|
||||||
|
return (
|
||||||
|
self_discovery_url
|
||||||
|
and other_discovery_url
|
||||||
|
and self_discovery_url.rstrip("/").lower()
|
||||||
|
== other_discovery_url.rstrip("/").lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the config flow."""
|
||||||
|
self._flow_state = FlowState()
|
||||||
|
self._client_config = ClientConfig()
|
||||||
|
self._feature_config = FeatureConfig()
|
||||||
|
self._discovery_cache = {}
|
||||||
|
self._cache_timestamps = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_provider_config(self) -> dict[str, Any]:
|
||||||
|
"""Get the configuration for the currently selected provider."""
|
||||||
|
if not self._flow_state.provider:
|
||||||
|
return {}
|
||||||
|
return OIDC_PROVIDERS.get(self._flow_state.provider, {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_provider_name(self) -> str:
|
||||||
|
"""Get the name of the currently selected provider."""
|
||||||
|
return get_provider_name(self._flow_state.provider)
|
||||||
|
|
||||||
|
def _cleanup_discovery_cache(self) -> None:
|
||||||
|
"""Remove expired and excess cache entries."""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Remove expired entries
|
||||||
|
expired_keys = [
|
||||||
|
key
|
||||||
|
for key, timestamp in self._cache_timestamps.items()
|
||||||
|
if current_time - timestamp > DISCOVERY_CACHE_TTL
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
self._discovery_cache.pop(key, None)
|
||||||
|
self._cache_timestamps.pop(key, None)
|
||||||
|
|
||||||
|
# Remove oldest entries if cache is too large
|
||||||
|
if len(self._discovery_cache) > MAX_CACHE_SIZE:
|
||||||
|
sorted_items = sorted(self._cache_timestamps.items(), key=lambda x: x[1])
|
||||||
|
excess_count = len(self._discovery_cache) - MAX_CACHE_SIZE
|
||||||
|
for key, _ in sorted_items[:excess_count]:
|
||||||
|
self._discovery_cache.pop(key, None)
|
||||||
|
self._cache_timestamps.pop(key, None)
|
||||||
|
|
||||||
|
def _is_cache_valid(self, cache_key: str) -> bool:
|
||||||
|
"""Check if a cache entry is still valid."""
|
||||||
|
if cache_key not in self._cache_timestamps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
age = time.time() - self._cache_timestamps[cache_key]
|
||||||
|
return age <= DISCOVERY_CACHE_TTL
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the initial step - provider selection."""
|
||||||
|
# Check if OIDC is already configured (only one instance allowed)
|
||||||
|
if self._async_current_entries():
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
# Check if YAML configuration exists
|
||||||
|
if self.hass.data.get(DOMAIN, {}).get("yaml_config"):
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self._flow_state.provider = user_input[CONF_PROVIDER]
|
||||||
|
|
||||||
|
# If provider has a predefined discovery URL, prefill it but still
|
||||||
|
# show the discovery URL step so the user can customize it.
|
||||||
|
predefined = self.current_provider_config.get("discovery_url")
|
||||||
|
if predefined:
|
||||||
|
self._flow_state.discovery_url = predefined
|
||||||
|
|
||||||
|
# Always request discovery URL next (prefilled when available)
|
||||||
|
return await self.async_step_discovery_url()
|
||||||
|
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_PROVIDER): vol.In(
|
||||||
|
{key: provider["name"] for key, provider in OIDC_PROVIDERS.items()}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_discovery_url(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle discovery URL input for providers requiring URL configuration."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
discovery_url = user_input[CONF_DISCOVERY_URL].rstrip("/")
|
||||||
|
|
||||||
|
# Validate discovery URL format
|
||||||
|
if not validate_discovery_url(discovery_url):
|
||||||
|
errors["discovery_url"] = "invalid_url_format"
|
||||||
|
else:
|
||||||
|
self._flow_state.discovery_url = discovery_url
|
||||||
|
return await self.async_step_client_config()
|
||||||
|
|
||||||
|
provider_name = self.current_provider_name
|
||||||
|
provider_key = self._flow_state.provider
|
||||||
|
|
||||||
|
# Pre-populate with existing discovery URL if available
|
||||||
|
default_url = (
|
||||||
|
self._flow_state.discovery_url
|
||||||
|
if self._flow_state.discovery_url
|
||||||
|
else vol.UNDEFINED
|
||||||
|
)
|
||||||
|
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{vol.Required(CONF_DISCOVERY_URL, default=default_url): str}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_url",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"provider_name": provider_name,
|
||||||
|
"documentation_url": get_provider_docs_url(provider_key),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_client_config(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle client ID and client type selection."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
client_id = user_input[CONF_CLIENT_ID]
|
||||||
|
|
||||||
|
# Validate client ID
|
||||||
|
if not validate_client_id(client_id):
|
||||||
|
errors["client_id"] = "invalid_client_id"
|
||||||
|
if not errors:
|
||||||
|
self._client_config.client_id = client_id.strip()
|
||||||
|
# Optional client secret determines confidential/public
|
||||||
|
provided_secret = sanitize_client_secret(
|
||||||
|
user_input.get(CONF_CLIENT_SECRET, "")
|
||||||
|
)
|
||||||
|
self._client_config.client_secret = provided_secret or None
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
# Proceed to validation directly from here
|
||||||
|
return await self.async_step_validate_connection()
|
||||||
|
|
||||||
|
provider_name = self.current_provider_name
|
||||||
|
|
||||||
|
# Pre-populate with existing values if available
|
||||||
|
default_client_id = (
|
||||||
|
self._client_config.client_id
|
||||||
|
if self._client_config.client_id
|
||||||
|
else vol.UNDEFINED
|
||||||
|
)
|
||||||
|
default_client_secret = (
|
||||||
|
self._client_config.client_secret
|
||||||
|
if self._client_config.client_secret
|
||||||
|
else vol.UNDEFINED
|
||||||
|
)
|
||||||
|
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(CONF_CLIENT_ID, default=default_client_id): str,
|
||||||
|
vol.Optional(CONF_CLIENT_SECRET, default=default_client_secret): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="client_config",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"provider_name": provider_name,
|
||||||
|
"discovery_url": self._flow_state.discovery_url,
|
||||||
|
"documentation_url": get_provider_docs_url(self._flow_state.provider),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_validation_actions(
|
||||||
|
self, user_input: dict[str, Any]
|
||||||
|
) -> FlowResult | None:
|
||||||
|
"""Handle user actions from the validation form so they can fix errors."""
|
||||||
|
action = user_input.get("action")
|
||||||
|
|
||||||
|
# Handle special actions first
|
||||||
|
if action == "retry":
|
||||||
|
return None # Continue with validation
|
||||||
|
if action == "continue":
|
||||||
|
return await self._proceed_to_next_step()
|
||||||
|
|
||||||
|
# Handle redirect actions
|
||||||
|
action_handlers = {
|
||||||
|
"fix_discovery": self.async_step_discovery_url,
|
||||||
|
"fix_client": self.async_step_client_config,
|
||||||
|
"change_provider": self.async_step_user,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler = action_handlers.get(action)
|
||||||
|
return await handler() if handler else None
|
||||||
|
|
||||||
|
async def _proceed_to_next_step(self) -> FlowResult:
|
||||||
|
"""Proceed to next step after successful validation."""
|
||||||
|
if self.current_provider_config.get("supports_groups", True):
|
||||||
|
return await self.async_step_groups_config()
|
||||||
|
return await self.async_step_user_linking()
|
||||||
|
|
||||||
|
async def _perform_oidc_validation(self) -> tuple[dict, dict]:
|
||||||
|
"""Perform the actual OIDC validation and return discovery doc and errors."""
|
||||||
|
errors = {}
|
||||||
|
discovery_doc = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a test OIDC client to validate configuration
|
||||||
|
test_client = OIDCClient(
|
||||||
|
hass=self.hass,
|
||||||
|
discovery_url=self._flow_state.discovery_url,
|
||||||
|
client_id=self._client_config.client_id,
|
||||||
|
scope="openid profile",
|
||||||
|
client_secret=self._client_config.client_secret or None,
|
||||||
|
features={},
|
||||||
|
claims={},
|
||||||
|
roles={},
|
||||||
|
network={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up expired cache entries first
|
||||||
|
self._cleanup_discovery_cache()
|
||||||
|
|
||||||
|
# Check if discovery document is already cached and valid
|
||||||
|
cache_key = self._flow_state.discovery_url
|
||||||
|
if cache_key in self._discovery_cache and self._is_cache_valid(cache_key):
|
||||||
|
discovery_doc = self._discovery_cache[cache_key]
|
||||||
|
# Still validate JWKS if available since this might be a retry
|
||||||
|
if "jwks_uri" in discovery_doc:
|
||||||
|
await test_client.validate_jwks(discovery_doc["jwks_uri"])
|
||||||
|
else:
|
||||||
|
# Perform discovery and JWKS validation
|
||||||
|
discovery_doc = await test_client.validate_discovery()
|
||||||
|
|
||||||
|
# Cache the discovery document with timestamp
|
||||||
|
self._discovery_cache[cache_key] = discovery_doc
|
||||||
|
self._cache_timestamps[cache_key] = time.time()
|
||||||
|
|
||||||
|
# Validate JWKS if available
|
||||||
|
if "jwks_uri" in discovery_doc:
|
||||||
|
await test_client.validate_jwks(discovery_doc["jwks_uri"])
|
||||||
|
|
||||||
|
except OIDCDiscoveryInvalid:
|
||||||
|
errors["base"] = "discovery_invalid"
|
||||||
|
except OIDCJWKSInvalid:
|
||||||
|
errors["base"] = "jwks_invalid"
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected error during validation")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
|
||||||
|
return discovery_doc, errors
|
||||||
|
|
||||||
|
def _get_action_options(self, has_errors: bool) -> dict[str, str]:
|
||||||
|
"""Get action options based on validation state."""
|
||||||
|
if has_errors:
|
||||||
|
return {
|
||||||
|
"retry": "Retry Validation",
|
||||||
|
"fix_client": "Fix Client Settings",
|
||||||
|
"fix_discovery": "Fix Discovery URL",
|
||||||
|
"change_provider": "Change Provider",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"continue": "Continue Setup",
|
||||||
|
"fix_client": "Modify Client Settings",
|
||||||
|
"fix_discovery": "Modify Discovery URL",
|
||||||
|
"change_provider": "Change Provider",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_discovery_success_details(self, discovery_doc: dict) -> str:
|
||||||
|
"""Build success details from discovery document."""
|
||||||
|
discovery_info = []
|
||||||
|
|
||||||
|
endpoints = [
|
||||||
|
("issuer", "✅ Connection Successful", "**Issuer:** {value}"),
|
||||||
|
("authorization_endpoint", "✅ Authorization endpoint found", None),
|
||||||
|
("token_endpoint", "✅ Token endpoint found", None),
|
||||||
|
("jwks_uri", "✅ JWKS endpoint found", None),
|
||||||
|
("userinfo_endpoint", "✅ User info endpoint found", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
for key, message, formatted in endpoints:
|
||||||
|
if key in discovery_doc:
|
||||||
|
discovery_info.append(message)
|
||||||
|
if formatted and key == "issuer":
|
||||||
|
discovery_info.append(formatted.format(value=discovery_doc[key]))
|
||||||
|
|
||||||
|
return "\n".join(discovery_info)
|
||||||
|
|
||||||
|
def _build_error_details(self, errors: dict[str, str]) -> str:
|
||||||
|
"""Build error details from validation errors."""
|
||||||
|
error_messages = {
|
||||||
|
"discovery_invalid": (
|
||||||
|
"❌ **Discovery document could not be retrieved**\n"
|
||||||
|
"Please verify the discovery URL is correct and accessible."
|
||||||
|
),
|
||||||
|
"jwks_invalid": (
|
||||||
|
"❌ **JWKS validation failed**\n"
|
||||||
|
"The JSON Web Key Set could not be retrieved or validated."
|
||||||
|
),
|
||||||
|
"cannot_connect": (
|
||||||
|
"❌ **Connection failed**\n"
|
||||||
|
"Unable to connect to the OIDC provider. Check your network and URL."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return error_messages.get(errors.get("base", ""), "")
|
||||||
|
|
||||||
|
async def _build_validation_form(
|
||||||
|
self, errors: dict[str, str], discovery_doc: dict | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Build the validation form with errors and action options."""
|
||||||
|
action_options = self._get_action_options(bool(errors))
|
||||||
|
data_schema = vol.Schema({vol.Required("action"): vol.In(action_options)})
|
||||||
|
|
||||||
|
# Build description with discovery details
|
||||||
|
description_placeholders = {
|
||||||
|
"discovery_url": self._flow_state.discovery_url,
|
||||||
|
"client_id": self._client_config.client_id,
|
||||||
|
"provider_name": self.current_provider_name,
|
||||||
|
"discovery_details": "",
|
||||||
|
"documentation_url": get_provider_docs_url(self._flow_state.provider),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add appropriate details based on validation state
|
||||||
|
if discovery_doc and not errors:
|
||||||
|
description_placeholders["discovery_details"] = (
|
||||||
|
self._build_discovery_success_details(discovery_doc)
|
||||||
|
)
|
||||||
|
elif errors:
|
||||||
|
description_placeholders["discovery_details"] = self._build_error_details(
|
||||||
|
errors
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="validate_connection",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders=description_placeholders,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_validate_connection(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Validate the OIDC configuration by testing discovery and JWKS."""
|
||||||
|
# Handle user actions from validation form
|
||||||
|
if user_input is not None:
|
||||||
|
action_result = await self._handle_validation_actions(user_input)
|
||||||
|
if action_result is not None:
|
||||||
|
return action_result
|
||||||
|
|
||||||
|
# Perform validation (either initial attempt or retry)
|
||||||
|
discovery_doc, errors = await self._perform_oidc_validation()
|
||||||
|
|
||||||
|
# Always show validation form with results (success or error)
|
||||||
|
return await self._build_validation_form(errors, discovery_doc)
|
||||||
|
|
||||||
|
async def async_step_groups_config(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Configure groups and roles."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self._feature_config.enable_groups = user_input.get(
|
||||||
|
CONF_ENABLE_GROUPS, False
|
||||||
|
)
|
||||||
|
if self._feature_config.enable_groups:
|
||||||
|
self._feature_config.admin_group = user_input.get(
|
||||||
|
CONF_ADMIN_GROUP, "admins"
|
||||||
|
)
|
||||||
|
self._feature_config.user_group = user_input.get(CONF_USER_GROUP)
|
||||||
|
|
||||||
|
return await self.async_step_user_linking()
|
||||||
|
|
||||||
|
default_admin_group = self.current_provider_config.get(
|
||||||
|
"default_admin_group", "admins"
|
||||||
|
)
|
||||||
|
|
||||||
|
data_schema_dict = {vol.Optional(CONF_ENABLE_GROUPS, default=True): bool}
|
||||||
|
|
||||||
|
# Add group configuration fields if groups are enabled
|
||||||
|
if user_input is None or user_input.get(CONF_ENABLE_GROUPS, True):
|
||||||
|
data_schema_dict.update(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_ADMIN_GROUP, default=default_admin_group): str,
|
||||||
|
vol.Optional(CONF_USER_GROUP): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
data_schema = vol.Schema(data_schema_dict)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="groups_config",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={"provider_name": self.current_provider_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user_linking(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Configure user linking options."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self._feature_config.enable_user_linking = user_input.get(
|
||||||
|
CONF_ENABLE_USER_LINKING, False
|
||||||
|
)
|
||||||
|
return await self.async_step_finalize()
|
||||||
|
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{vol.Optional(CONF_ENABLE_USER_LINKING, default=False): bool}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user_linking",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_finalize(self) -> FlowResult:
|
||||||
|
"""Finalize the configuration and create the config entry."""
|
||||||
|
await self.async_set_unique_id(DOMAIN)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
# Build the configuration
|
||||||
|
config_data = {
|
||||||
|
"provider": self._flow_state.provider,
|
||||||
|
"client_id": self._client_config.client_id,
|
||||||
|
"discovery_url": self._flow_state.discovery_url,
|
||||||
|
"display_name": f"{self.current_provider_name} (OIDC)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional fields
|
||||||
|
if self._client_config.client_secret:
|
||||||
|
config_data["client_secret"] = self._client_config.client_secret
|
||||||
|
|
||||||
|
# Configure features
|
||||||
|
features = {
|
||||||
|
"automatic_user_linking": self._feature_config.enable_user_linking,
|
||||||
|
"automatic_person_creation": True,
|
||||||
|
"include_groups_scope": self._feature_config.enable_groups,
|
||||||
|
}
|
||||||
|
config_data["features"] = features
|
||||||
|
|
||||||
|
# Configure claims using provider defaults
|
||||||
|
claims = self.current_provider_config["claims"].copy()
|
||||||
|
config_data["claims"] = claims
|
||||||
|
|
||||||
|
# Configure roles if groups are enabled
|
||||||
|
if self._feature_config.enable_groups:
|
||||||
|
roles = {}
|
||||||
|
if self._feature_config.admin_group:
|
||||||
|
roles["admin"] = self._feature_config.admin_group
|
||||||
|
if self._feature_config.user_group:
|
||||||
|
roles["user"] = self._feature_config.user_group
|
||||||
|
config_data["roles"] = roles
|
||||||
|
|
||||||
|
title = f"{self.current_provider_name} OIDC"
|
||||||
|
|
||||||
|
return self.async_create_entry(title=title, data=config_data)
|
||||||
|
|
||||||
|
async def _validate_reconfigure_input(
|
||||||
|
self, entry, user_input: dict[str, Any]
|
||||||
|
) -> tuple[dict[str, str], dict[str, Any] | None]:
|
||||||
|
"""Validate reconfigure input and return errors and data updates."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
# Validate client ID
|
||||||
|
client_id = user_input[CONF_CLIENT_ID].strip()
|
||||||
|
if not validate_client_id(client_id):
|
||||||
|
errors["client_id"] = "invalid_client_id"
|
||||||
|
return errors, None
|
||||||
|
|
||||||
|
# Determine confidentiality by presence of client secret
|
||||||
|
client_secret = user_input.get(CONF_CLIENT_SECRET, "").strip()
|
||||||
|
# If secret is empty, keep the existing one (if any)
|
||||||
|
if not client_secret:
|
||||||
|
client_secret = entry.data.get("client_secret")
|
||||||
|
|
||||||
|
# Test the new configuration
|
||||||
|
test_client = OIDCClient(
|
||||||
|
hass=self.hass,
|
||||||
|
discovery_url=entry.data["discovery_url"],
|
||||||
|
client_id=client_id,
|
||||||
|
scope="openid profile",
|
||||||
|
client_secret=client_secret or None,
|
||||||
|
features={},
|
||||||
|
claims={},
|
||||||
|
roles={},
|
||||||
|
network={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate the new credentials
|
||||||
|
discovery_doc = await test_client.validate_discovery()
|
||||||
|
if "jwks_uri" in discovery_doc:
|
||||||
|
await test_client.validate_jwks(discovery_doc["jwks_uri"])
|
||||||
|
|
||||||
|
# Build updated data
|
||||||
|
data_updates = {"client_id": client_id}
|
||||||
|
|
||||||
|
if client_secret:
|
||||||
|
data_updates["client_secret"] = client_secret
|
||||||
|
elif "client_secret" in entry.data and not client_secret:
|
||||||
|
# Remove client secret if switching from confidential to public
|
||||||
|
data_updates = {**entry.data, **data_updates}
|
||||||
|
data_updates.pop("client_secret", None)
|
||||||
|
|
||||||
|
return errors, data_updates
|
||||||
|
|
||||||
|
def _build_reconfigure_schema(
|
||||||
|
self, current_data: dict[str, Any], _user_input: dict[str, Any] | None
|
||||||
|
) -> vol.Schema:
|
||||||
|
"""Build the reconfigure form schema."""
|
||||||
|
schema_dict = {
|
||||||
|
vol.Required(
|
||||||
|
CONF_CLIENT_ID, default=current_data.get("client_id", vol.UNDEFINED)
|
||||||
|
): str,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Always allow updating or clearing the client secret
|
||||||
|
schema_dict[vol.Optional(CONF_CLIENT_SECRET)] = str
|
||||||
|
|
||||||
|
return vol.Schema(schema_dict)
|
||||||
|
|
||||||
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle reconfiguration of OIDC client credentials."""
|
||||||
|
errors = {}
|
||||||
|
entry = self._get_reconfigure_entry()
|
||||||
|
if entry is None:
|
||||||
|
return self.async_abort(reason="unknown")
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
try:
|
||||||
|
errors, data_updates = await self._validate_reconfigure_input(
|
||||||
|
entry, user_input
|
||||||
|
)
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
# Update the config entry
|
||||||
|
await self.async_set_unique_id(entry.unique_id)
|
||||||
|
self._abort_if_unique_id_mismatch()
|
||||||
|
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
entry, data_updates=data_updates
|
||||||
|
)
|
||||||
|
|
||||||
|
except OIDCDiscoveryInvalid:
|
||||||
|
errors["base"] = "discovery_invalid"
|
||||||
|
except OIDCJWKSInvalid:
|
||||||
|
errors["base"] = "jwks_invalid"
|
||||||
|
except aiohttp.ClientError:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
_LOGGER.exception("Unexpected error during reconfiguration")
|
||||||
|
errors["base"] = "unknown"
|
||||||
|
|
||||||
|
# Show form
|
||||||
|
current_data = entry.data
|
||||||
|
data_schema = self._build_reconfigure_schema(current_data, user_input)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="reconfigure",
|
||||||
|
data_schema=data_schema,
|
||||||
|
errors=errors,
|
||||||
|
description_placeholders={
|
||||||
|
"provider_name": get_provider_name(current_data.get("provider")),
|
||||||
|
"discovery_url": current_data.get("discovery_url", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_reconfigure_entry(self):
|
||||||
|
"""Return the config entry being reconfigured if available.
|
||||||
|
|
||||||
|
Prefer the entry referenced by the flow context's entry_id. Fall back to the
|
||||||
|
first existing entry for this domain when only a single instance is allowed.
|
||||||
|
"""
|
||||||
|
# Try from flow context (preferred)
|
||||||
|
entry_id = None
|
||||||
|
context = getattr(self, "context", None)
|
||||||
|
if context and hasattr(context, "get"):
|
||||||
|
entry_id = context.get("entry_id")
|
||||||
|
|
||||||
|
if entry_id:
|
||||||
|
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||||
|
if entry and entry.domain == DOMAIN:
|
||||||
|
return entry
|
||||||
|
|
||||||
|
# Fallback: this integration allows a single instance; use the first
|
||||||
|
current = self._async_current_entries()
|
||||||
|
if current:
|
||||||
|
return current[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry):
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return OIDCOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCOptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
|
"""Handle options flow for OIDC Authentication."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry):
|
||||||
|
"""Initialize options flow."""
|
||||||
|
self.config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(self, user_input=None):
|
||||||
|
"""Handle options flow."""
|
||||||
|
if user_input is not None:
|
||||||
|
# Process the updated configuration
|
||||||
|
updated_features = {
|
||||||
|
"automatic_user_linking": user_input.get("enable_user_linking", False),
|
||||||
|
"include_groups_scope": user_input.get("enable_groups", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
updated_roles = {}
|
||||||
|
if user_input.get("enable_groups", False):
|
||||||
|
if user_input.get("admin_group"):
|
||||||
|
updated_roles["admin"] = user_input["admin_group"]
|
||||||
|
if user_input.get("user_group"):
|
||||||
|
updated_roles["user"] = user_input["user_group"]
|
||||||
|
|
||||||
|
# Update the config entry data
|
||||||
|
new_data = self.config_entry.data.copy()
|
||||||
|
new_data["features"] = {**new_data.get("features", {}), **updated_features}
|
||||||
|
if updated_roles:
|
||||||
|
new_data["roles"] = updated_roles
|
||||||
|
elif "roles" in new_data:
|
||||||
|
# Remove roles if groups are disabled
|
||||||
|
if not user_input.get("enable_groups", False):
|
||||||
|
del new_data["roles"]
|
||||||
|
|
||||||
|
# Update the config entry
|
||||||
|
self.hass.config_entries.async_update_entry(
|
||||||
|
self.config_entry, data=new_data
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_create_entry(title="", data={})
|
||||||
|
|
||||||
|
current_config = self.config_entry.data
|
||||||
|
current_features = current_config.get("features", {})
|
||||||
|
current_roles = current_config.get("roles", {})
|
||||||
|
|
||||||
|
# Determine if this provider supports groups
|
||||||
|
provider = current_config.get("provider", "authentik")
|
||||||
|
provider_supports_groups = OIDC_PROVIDERS.get(provider, {}).get(
|
||||||
|
"supports_groups", True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build schema based on provider capabilities
|
||||||
|
schema_dict = {
|
||||||
|
vol.Optional(
|
||||||
|
"enable_user_linking",
|
||||||
|
default=current_features.get("automatic_user_linking", False),
|
||||||
|
): bool
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add groups options if provider supports them
|
||||||
|
if provider_supports_groups:
|
||||||
|
enable_groups_default = current_features.get("include_groups_scope", False)
|
||||||
|
schema_dict[
|
||||||
|
vol.Optional("enable_groups", default=enable_groups_default)
|
||||||
|
] = bool
|
||||||
|
|
||||||
|
# Add group name fields if groups are currently enabled or being enabled
|
||||||
|
if enable_groups_default or (
|
||||||
|
user_input and user_input.get("enable_groups", False)
|
||||||
|
):
|
||||||
|
schema_dict.update(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
"admin_group",
|
||||||
|
default=current_roles.get("admin", DEFAULT_ADMIN_GROUP),
|
||||||
|
): str,
|
||||||
|
vol.Optional(
|
||||||
|
"user_group", default=current_roles.get("user", "")
|
||||||
|
): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=vol.Schema(schema_dict),
|
||||||
|
description_placeholders={
|
||||||
|
"provider_name": get_provider_name(provider),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"codeowners": [
|
"codeowners": [
|
||||||
"@christiaangoossens"
|
"@christiaangoossens"
|
||||||
],
|
],
|
||||||
"config_flow": false,
|
"config_flow": true,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"auth",
|
"auth",
|
||||||
"http"
|
"http"
|
||||||
|
|||||||
@@ -532,3 +532,21 @@ class OIDCClient:
|
|||||||
except OIDCClientException as e:
|
except OIDCClientException as e:
|
||||||
_LOGGER.warning("Failed to complete token flow, returning None. (%s)", e)
|
_LOGGER.warning("Failed to complete token flow, returning None. (%s)", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def validate_discovery(self):
|
||||||
|
"""Validate that the discovery document can be fetched and is valid.
|
||||||
|
|
||||||
|
Public method for configuration validation.
|
||||||
|
Returns the discovery document if valid.
|
||||||
|
Raises OIDCDiscoveryInvalid if invalid.
|
||||||
|
"""
|
||||||
|
return await self._fetch_discovery_document()
|
||||||
|
|
||||||
|
async def validate_jwks(self, jwks_uri: str):
|
||||||
|
"""Validate that the JWKS can be fetched from the given URI.
|
||||||
|
|
||||||
|
Public method for configuration validation.
|
||||||
|
Returns the JWKS if valid.
|
||||||
|
Raises OIDCJWKSInvalid if invalid.
|
||||||
|
"""
|
||||||
|
return await self._get_jwks(jwks_uri)
|
||||||
|
|||||||
104
custom_components/auth_oidc/provider_catalog.py
Normal file
104
custom_components/auth_oidc/provider_catalog.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Provider catalog and helpers for OIDC providers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_GROUP = "admins"
|
||||||
|
|
||||||
|
|
||||||
|
OIDC_PROVIDERS: Dict[str, Dict[str, Any]] = {
|
||||||
|
"authentik": {
|
||||||
|
"name": "Authentik",
|
||||||
|
"discovery_url": "",
|
||||||
|
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||||
|
"supports_groups": True,
|
||||||
|
"claims": {
|
||||||
|
"display_name": "name",
|
||||||
|
"username": "preferred_username",
|
||||||
|
"groups": "groups",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"authelia": {
|
||||||
|
"name": "Authelia",
|
||||||
|
"discovery_url": "",
|
||||||
|
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||||
|
"supports_groups": True,
|
||||||
|
"claims": {
|
||||||
|
"display_name": "name",
|
||||||
|
"username": "preferred_username",
|
||||||
|
"groups": "groups",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"pocketid": {
|
||||||
|
"name": "Pocket ID",
|
||||||
|
"discovery_url": "",
|
||||||
|
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||||
|
"supports_groups": True,
|
||||||
|
"claims": {
|
||||||
|
"display_name": "name",
|
||||||
|
"username": "preferred_username",
|
||||||
|
"groups": "groups",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kanidm": {
|
||||||
|
"name": "Kanidm",
|
||||||
|
"discovery_url": "",
|
||||||
|
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||||
|
"supports_groups": True,
|
||||||
|
"claims": {
|
||||||
|
"display_name": "name",
|
||||||
|
"username": "preferred_username",
|
||||||
|
"groups": "groups",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"microsoft": {
|
||||||
|
"name": "Microsoft Entra ID",
|
||||||
|
"discovery_url": (
|
||||||
|
"https://login.microsoftonline.com/common/v2.0/"
|
||||||
|
".well-known/openid_configuration"
|
||||||
|
),
|
||||||
|
"default_admin_group": DEFAULT_ADMIN_GROUP,
|
||||||
|
"supports_groups": True,
|
||||||
|
"claims": {
|
||||||
|
"display_name": "name",
|
||||||
|
"username": "preferred_username",
|
||||||
|
"groups": "groups",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_config(key: str) -> Dict[str, Any]:
|
||||||
|
"""Return provider configuration by key."""
|
||||||
|
return OIDC_PROVIDERS.get(key, {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_name(key: str | None) -> str:
|
||||||
|
"""Return provider display name by key."""
|
||||||
|
if not key:
|
||||||
|
return "Unknown Provider"
|
||||||
|
return OIDC_PROVIDERS.get(key, {}).get("name", "Unknown Provider")
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_docs_url(key: str | None) -> str:
|
||||||
|
"""Return documentation URL for a provider key."""
|
||||||
|
base_url = (
|
||||||
|
"https://github.com/christiaangoossens/hass-oidc-auth/blob/main"
|
||||||
|
"/docs/provider-configurations"
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_docs = {
|
||||||
|
"authentik": f"{base_url}/authentik.md",
|
||||||
|
"authelia": f"{base_url}/authelia.md",
|
||||||
|
"pocketid": f"{base_url}/pocket-id.md",
|
||||||
|
"kanidm": f"{base_url}/kanidm.md",
|
||||||
|
"microsoft": f"{base_url}/microsoft-entra.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
if key in provider_docs:
|
||||||
|
return provider_docs[key]
|
||||||
|
return (
|
||||||
|
"https://github.com/christiaangoossens/hass-oidc-auth"
|
||||||
|
"/blob/main/docs/configuration.md"
|
||||||
|
)
|
||||||
104
custom_components/auth_oidc/strings.json
Normal file
104
custom_components/auth_oidc/strings.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Choose OIDC Provider",
|
||||||
|
"description": "Select your OpenID Connect identity provider to get started with the setup.",
|
||||||
|
"data": {
|
||||||
|
"provider": "Provider"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discovery_url": {
|
||||||
|
"title": "Provider Configuration",
|
||||||
|
"description": "Enter the discovery URL for {provider_name}. This is typically found in your provider's documentation and usually ends with '/.well-known/openid-configuration'.\n\nNeed detailed setup instructions? See the [provider guide]({documentation_url}).",
|
||||||
|
"data": {
|
||||||
|
"discovery_url": "Discovery URL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client_config": {
|
||||||
|
"title": "Client Configuration",
|
||||||
|
"description": "Configure your OIDC client. You can find these details in your {provider_name} application settings.\n\n**Discovery URL:** {discovery_url}\n\n**Setup Instructions:**\n1. Register a new application in your OIDC provider\n2. Set the application type to 'Public Client' (recommended for most users)\n3. Add redirect URLs for Home Assistant\n4. Copy the Client ID below\n\n**Note:** If your provider requires a client secret, check 'Use Confidential Client' and provide your client secret below.\n\n**Need detailed setup instructions?** Check the [setup guide]({documentation_url}) for step-by-step instructions.",
|
||||||
|
"data": {
|
||||||
|
"client_id": "Client ID",
|
||||||
|
"client_secret": "Client Secret (optional; required by some providers)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client_secret": {
|
||||||
|
"title": "Client Secret Configuration",
|
||||||
|
"description": "Since you selected 'Confidential Client', please provide your client secret.\n\n**Provider:** {provider_name}\n**Client ID:** {client_id}\n**Discovery URL:** {discovery_url}\n\n**Security Note:** The client secret will be stored securely in Home Assistant's configuration. Never share your client secret with others.",
|
||||||
|
"data": {
|
||||||
|
"client_secret": "Client Secret"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validate_connection": {
|
||||||
|
"title": "Connection Validation",
|
||||||
|
"description": "Testing connection to your {provider_name} OIDC provider...\n\n**Discovery URL:** {discovery_url}\n**Client ID:** {client_id}\n\n{discovery_details}\n\n**What to do next:**\n- **Continue Setup:** Proceed with the configuration (when validation succeeds)\n- **Retry Validation:** Test the connection again with current settings\n- **Modify Client Settings:** Go back to change Client ID or secret\n- **Modify Discovery URL:** Go back to change the discovery URL\n- **Change Provider:** Start over with a different provider\n\n**Need Help?** Check the [setup documentation]({documentation_url}) for detailed configuration instructions.",
|
||||||
|
"data": {
|
||||||
|
"action": "Choose an action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groups_config": {
|
||||||
|
"title": "Groups & Role Configuration",
|
||||||
|
"description": "Configure how user groups from {provider_name} should be mapped to Home Assistant roles.\n\n**Groups Support:** Groups allow you to automatically assign admin or user roles based on group membership in your identity provider.\n\n**Admin Group:** Users in this group will have administrator access\n**User Group:** Users in this group will have standard user access (leave empty to allow all authenticated users)",
|
||||||
|
"data": {
|
||||||
|
"enable_groups": "Enable group-based role assignment",
|
||||||
|
"admin_group": "Admin group name",
|
||||||
|
"user_group": "User group name (optional)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_linking": {
|
||||||
|
"title": "User Linking Options",
|
||||||
|
"description": "Configure how OIDC users are linked to existing Home Assistant users.\n\n**⚠️ Important Security Information:**\n\n**User Linking Disabled (Recommended):** New OIDC accounts are created for each user. This is the most secure option.\n\n**User Linking Enabled:** OIDC users can be linked to existing Home Assistant users by username. **This has security implications:**\n- If someone can guess or obtain a Home Assistant username, they might gain access to that account\n- Only enable this if you're migrating from local Home Assistant accounts to OIDC\n- You can disable this later if needed",
|
||||||
|
"data": {
|
||||||
|
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finalize": {
|
||||||
|
"title": "Setup Complete",
|
||||||
|
"description": "Your OIDC authentication is now configured and ready to use.\n\n**Next Steps:**\n1. Save this configuration\n2. Restart Home Assistant if prompted\n3. The OIDC login option will appear on your login screen\n\n**Advanced Configuration:**\nAdvanced options like custom networking settings, specific claim configurations, or custom scopes are only available through YAML configuration. See the documentation for details.",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Reconfigure OIDC Authentication",
|
||||||
|
"description": "Update your OIDC client credentials for {provider_name}.\n\n**Discovery URL:** {discovery_url}\n\n**What you can change:**\n- **Client ID**: Update your application's client identifier\n- **Client Type**: Switch between Public and Confidential client types\n- **Client Secret**: Update or add a client secret (for confidential clients)\n\n**Note:** Changes will be validated against your OIDC provider before being saved. Your existing settings will be preserved if validation fails.\n\n**Security:** For confidential clients, leave the client secret field empty to keep your existing secret unchanged.",
|
||||||
|
"data": {
|
||||||
|
"client_id": "Client ID",
|
||||||
|
"client_secret": "Client Secret (leave empty to keep current)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect to the OIDC provider. Please check your network connection and discovery URL.",
|
||||||
|
"discovery_invalid": "The discovery document could not be retrieved or is invalid. Please verify the discovery URL is correct.",
|
||||||
|
"jwks_invalid": "Failed to retrieve or validate the JWKS (JSON Web Key Set). Please check your provider configuration.",
|
||||||
|
"invalid_client_credentials": "The client ID or client secret appears to be invalid. Please check your OIDC application settings and ensure the credentials are correct.",
|
||||||
|
"client_secret_required": "Client secret is required when using confidential client mode.",
|
||||||
|
"invalid_url_format": "The discovery URL must be a valid HTTP or HTTPS URL.",
|
||||||
|
"invalid_client_id": "Client ID cannot be empty and must contain valid characters.",
|
||||||
|
"no_url_available": "Unable to determine Home Assistant URL for OAuth redirect. Please check your network configuration.",
|
||||||
|
"auth_url_failed": "Failed to generate authorization URL for OAuth test.",
|
||||||
|
"unknown": "An unexpected error occurred. Please check the logs for more details."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This OIDC provider is already configured.",
|
||||||
|
"cannot_connect": "Unable to connect to the OIDC provider.",
|
||||||
|
"invalid_discovery": "Invalid discovery document received from the provider.",
|
||||||
|
"reconfigure_successful": "OIDC Authentication has been successfully reconfigured with the updated client credentials.",
|
||||||
|
"single_instance_allowed": "OIDC Authentication only supports a single configuration. You already have OIDC configured (either through YAML or the UI). To modify your existing configuration, go to Settings > Devices & Services > OIDC Authentication and click 'Configure'. To replace your configuration, first remove the existing one."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "OIDC Authentication Options",
|
||||||
|
"description": "Update configuration options for your {provider_name} OIDC authentication.\n\n**User Linking:** Control how OIDC users are linked to existing Home Assistant accounts (⚠️ security implications).\n\n**Groups Configuration:** Configure role assignment based on group membership from your identity provider.\n\n**Note:** Changes take effect immediately but may require users to log out and back in.",
|
||||||
|
"data": {
|
||||||
|
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)",
|
||||||
|
"enable_groups": "Enable group-based role assignment",
|
||||||
|
"admin_group": "Admin group name",
|
||||||
|
"user_group": "User group name (optional - leave empty to allow all authenticated users)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
custom_components/auth_oidc/translations/en.json
Normal file
104
custom_components/auth_oidc/translations/en.json
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"title": "Choose OIDC Provider",
|
||||||
|
"description": "Select your OpenID Connect identity provider to get started with the setup.",
|
||||||
|
"data": {
|
||||||
|
"provider": "Provider"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discovery_url": {
|
||||||
|
"title": "Provider Configuration",
|
||||||
|
"description": "Enter the discovery URL for {provider_name}. This is typically found in your provider's documentation and usually ends with '/.well-known/openid-configuration'.\n\nNeed detailed setup instructions? See the [provider guide]({documentation_url}).",
|
||||||
|
"data": {
|
||||||
|
"discovery_url": "Discovery URL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client_config": {
|
||||||
|
"title": "Client Configuration",
|
||||||
|
"description": "Configure your OIDC client. You can find these details in your {provider_name} application settings.\n\n**Discovery URL:** {discovery_url}\n\n**Setup Instructions:**\n1. Register a new application in your OIDC provider\n2. Set the application type to 'Public Client' (recommended for most users)\n3. Add redirect URLs for Home Assistant\n4. Copy the Client ID below\n\n**Note:** If your provider requires a client secret, check 'Use Confidential Client' and provide your client secret below.\n\n**Need detailed setup instructions?** Check the [setup guide]({documentation_url}) for step-by-step instructions.",
|
||||||
|
"data": {
|
||||||
|
"client_id": "Client ID",
|
||||||
|
"client_secret": "Client Secret (optional; required by some providers)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"client_secret": {
|
||||||
|
"title": "Client Secret Configuration",
|
||||||
|
"description": "Since you selected 'Confidential Client', please provide your client secret.\n\n**Provider:** {provider_name}\n**Client ID:** {client_id}\n**Discovery URL:** {discovery_url}\n\n**Security Note:** The client secret will be stored securely in Home Assistant's configuration. Never share your client secret with others.",
|
||||||
|
"data": {
|
||||||
|
"client_secret": "Client Secret"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validate_connection": {
|
||||||
|
"title": "Connection Validation",
|
||||||
|
"description": "Testing connection to your {provider_name} OIDC provider...\n\n**Discovery URL:** {discovery_url}\n**Client ID:** {client_id}\n\n{discovery_details}\n\n**What to do next:**\n- **Continue Setup:** Proceed with the configuration (when validation succeeds)\n- **Retry Validation:** Test the connection again with current settings\n- **Modify Client Settings:** Go back to change Client ID or secret\n- **Modify Discovery URL:** Go back to change the discovery URL\n- **Change Provider:** Start over with a different provider\n\n**Need Help?** Check the [setup documentation]({documentation_url}) for detailed configuration instructions.",
|
||||||
|
"data": {
|
||||||
|
"action": "Choose an action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"groups_config": {
|
||||||
|
"title": "Groups & Role Configuration",
|
||||||
|
"description": "Configure how user groups from {provider_name} should be mapped to Home Assistant roles.\n\n**Groups Support:** Groups allow you to automatically assign admin or user roles based on group membership in your identity provider.\n\n**Admin Group:** Users in this group will have administrator access\n**User Group:** Users in this group will have standard user access (leave empty to allow all authenticated users)",
|
||||||
|
"data": {
|
||||||
|
"enable_groups": "Enable group-based role assignment",
|
||||||
|
"admin_group": "Admin group name",
|
||||||
|
"user_group": "User group name (optional)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"user_linking": {
|
||||||
|
"title": "User Linking Options",
|
||||||
|
"description": "Configure how OIDC users are linked to existing Home Assistant users.\n\n**⚠️ Important Security Information:**\n\n**User Linking Disabled (Recommended):** New OIDC accounts are created for each user. This is the most secure option.\n\n**User Linking Enabled:** OIDC users can be linked to existing Home Assistant users by username. **This has security implications:**\n- If someone can guess or obtain a Home Assistant username, they might gain access to that account\n- Only enable this if you're migrating from local Home Assistant accounts to OIDC\n- You can disable this later if needed",
|
||||||
|
"data": {
|
||||||
|
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"finalize": {
|
||||||
|
"title": "Setup Complete",
|
||||||
|
"description": "Your OIDC authentication is now configured and ready to use.\n\n**Next Steps:**\n1. Save this configuration\n2. Restart Home Assistant if prompted\n3. The OIDC login option will appear on your login screen\n\n**Advanced Configuration:**\nAdvanced options like custom networking settings, specific claim configurations, or custom scopes are only available through YAML configuration. See the documentation for details.",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "Reconfigure OIDC Authentication",
|
||||||
|
"description": "Update your OIDC client credentials for {provider_name}.\n\n**Discovery URL:** {discovery_url}\n\n**What you can change:**\n- **Client ID**: Update your application's client identifier\n- **Client Type**: Switch between Public and Confidential client types\n- **Client Secret**: Update or add a client secret (for confidential clients)\n\n**Note:** Changes will be validated against your OIDC provider before being saved. Your existing settings will be preserved if validation fails.\n\n**Security:** For confidential clients, leave the client secret field empty to keep your existing secret unchanged.",
|
||||||
|
"data": {
|
||||||
|
"client_id": "Client ID",
|
||||||
|
"client_secret": "Client Secret (leave empty to keep current)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "Failed to connect to the OIDC provider. Please check your network connection and discovery URL.",
|
||||||
|
"discovery_invalid": "The discovery document could not be retrieved or is invalid. Please verify the discovery URL is correct.",
|
||||||
|
"jwks_invalid": "Failed to retrieve or validate the JWKS (JSON Web Key Set). Please check your provider configuration.",
|
||||||
|
"invalid_client_credentials": "The client ID or client secret appears to be invalid. Please check your OIDC application settings and ensure the credentials are correct.",
|
||||||
|
"client_secret_required": "Client secret is required when using confidential client mode.",
|
||||||
|
"invalid_url_format": "The discovery URL must be a valid HTTP or HTTPS URL.",
|
||||||
|
"invalid_client_id": "Client ID cannot be empty and must contain valid characters.",
|
||||||
|
"no_url_available": "Unable to determine Home Assistant URL for OAuth redirect. Please check your network configuration.",
|
||||||
|
"auth_url_failed": "Failed to generate authorization URL for OAuth test.",
|
||||||
|
"unknown": "An unexpected error occurred. Please check the logs for more details."
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "This OIDC provider is already configured.",
|
||||||
|
"cannot_connect": "Unable to connect to the OIDC provider.",
|
||||||
|
"invalid_discovery": "Invalid discovery document received from the provider.",
|
||||||
|
"reconfigure_successful": "OIDC Authentication has been successfully reconfigured with the updated client credentials.",
|
||||||
|
"single_instance_allowed": "OIDC Authentication only supports a single configuration. You already have OIDC configured (either through YAML or the UI). To modify your existing configuration, go to Settings > Devices & Services > OIDC Authentication and click 'Configure'. To replace your configuration, first remove the existing one."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"title": "OIDC Authentication Options",
|
||||||
|
"description": "Update configuration options for your {provider_name} OIDC authentication.\n\n**User Linking:** Control how OIDC users are linked to existing Home Assistant accounts (⚠️ security implications).\n\n**Groups Configuration:** Configure role assignment based on group membership from your identity provider.\n\n**Note:** Changes take effect immediately but may require users to log out and back in.",
|
||||||
|
"data": {
|
||||||
|
"enable_user_linking": "Enable automatic user linking (⚠️ Security Risk)",
|
||||||
|
"enable_groups": "Enable group-based role assignment",
|
||||||
|
"admin_group": "Admin group name",
|
||||||
|
"user_group": "User group name (optional - leave empty to allow all authenticated users)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
custom_components/auth_oidc/validation.py
Normal file
24
custom_components/auth_oidc/validation.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Validation and sanitization helpers for config flow inputs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def validate_discovery_url(url: str) -> bool:
|
||||||
|
"""Validate that a URL is properly formatted for OIDC discovery."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url.strip())
|
||||||
|
return bool(parsed.scheme in ("http", "https") and parsed.netloc)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_client_secret(secret: str) -> str:
|
||||||
|
"""Sanitize client secret input."""
|
||||||
|
return secret.strip() if secret else ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_client_id(client_id: str) -> bool:
|
||||||
|
"""Validate client ID format."""
|
||||||
|
return bool(client_id and client_id.strip())
|
||||||
Reference in New Issue
Block a user