Improved config options for OIDC (#9)

Added many new configuration options, including claim configuration and client_secret/confidential client support. Also enables user linking & creates person entries upon first sign in.
This commit is contained in:
Christiaan Goossens
2024-12-28 21:37:00 +01:00
committed by GitHub
parent ca83e86acb
commit db4c6bcade
18 changed files with 631 additions and 183 deletions

View File

@@ -6,6 +6,7 @@ import logging
from typing import Dict, Optional
import asyncio
from homeassistant.auth import EVENT_USER_ADDED
from homeassistant.auth.providers import (
AUTH_PROVIDERS,
AuthProvider,
@@ -13,15 +14,29 @@ from homeassistant.auth.providers import (
AuthFlowResult,
Credentials,
UserMeta,
User,
AuthStore,
)
from homeassistant.components import http
from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.components import http, person
from homeassistant.exceptions import HomeAssistantError
import voluptuous as vol
from .config import (
FEATURES,
FEATURES_AUTOMATIC_USER_LINKING,
FEATURES_AUTOMATIC_PERSON_CREATION,
DEFAULT_TITLE,
)
from .stores.code_store import CodeStore
from .types import UserDetails
_LOGGER = logging.getLogger(__name__)
PROVIDER_TYPE = "auth_oidc"
HASS_PROVIDER_TYPE = "homeassistant"
class InvalidAuthError(HomeAssistantError):
"""Raised when submitting invalid authentication."""
@@ -32,23 +47,44 @@ class OpenIDAuthProvider(AuthProvider):
"""Allow access to users based on login with an external
OpenID Connect Identity Provider (IdP)."""
DEFAULT_TITLE = "OpenID Connect (SSO)"
@property
def type(self) -> str:
return "auth_oidc"
@property
def support_mfa(self) -> bool:
return False
def __init__(self, *args, **kwargs):
def __init__(self, hass: HomeAssistant, store: AuthStore, config: dict[str, str]):
"""Initialize the OpenIDAuthProvider."""
super().__init__(*args, **kwargs)
self._user_meta = {}
super().__init__(
hass,
store,
{
# Currently register as default, might be used when we have multiple OIDC providers
CONF_ID: "default",
# Name displayed in the UI
CONF_NAME: config.get("display_name", DEFAULT_TITLE),
# Type
CONF_TYPE: PROVIDER_TYPE,
},
)
self._user_meta: dict[UserDetails] = {}
self._code_store: CodeStore | None = None
self._init_lock = asyncio.Lock()
features = config.get(
FEATURES,
{},
)
# Link users automatically?
# False by default to always make new accounts for OIDC users
# Turn this on to migrate from HA accounts to OIDC
self.user_linking = features.get(FEATURES_AUTOMATIC_USER_LINKING, False)
# Create person entries automatically?
# True by default to create a person for each new user (just like normal HA)
# Turn this off if you don't want OIDC to interfere more than necessary
self.create_persons = features.get(FEATURES_AUTOMATIC_PERSON_CREATION, True)
async def async_initialize(self) -> None:
"""Initialize the auth provider."""
@@ -64,8 +100,11 @@ class OpenIDAuthProvider(AuthProvider):
self._code_store = store
self._user_meta = {}
async def async_retrieve_username(self, code: str) -> Optional[str]:
"""Retrieve user from the code, return username and save meta
# Listen for user creation events
self.hass.bus.async_listen(EVENT_USER_ADDED, self.async_user_created)
async def async_get_subject(self, code: str) -> Optional[str]:
"""Retrieve user from the code, return subject and save meta
for later use with this provider instance."""
if self._code_store is None:
await self.async_initialize()
@@ -75,9 +114,9 @@ class OpenIDAuthProvider(AuthProvider):
if user_data is None:
return None
username = user_data["username"]
self._user_meta[username] = user_data
return username
sub = user_data["sub"]
self._user_meta[sub] = user_data
return sub
async def async_save_user_info(self, user_info: dict[str, dict | str]) -> str:
"""Save user info and return a code."""
@@ -87,6 +126,77 @@ class OpenIDAuthProvider(AuthProvider):
return await self._code_store.async_generate_code_for_userinfo(user_info)
async def _async_find_user_by_username(self, username: str) -> Optional[User]:
"""Find a user by username."""
users = await self.store.async_get_users()
for user in users:
# System generated users don't have usernames and aren't our target here
if user.system_generated:
continue
# Check if we have a homeassistant credential with the provided username
for credential in user.credentials:
if (
credential.auth_provider_type == HASS_PROVIDER_TYPE
and credential.data.get("username") == username
):
return user
return None
# ====
# Handler for user created and related functions (person creation)
# ====
@callback
async def async_user_created(self, event) -> None:
"""Handle the user created event."""
user_id = event.data["user_id"]
user = await self.store.async_get_user(user_id)
# Get the first credential, if it's not ours, return
if not user.credentials or len(user.credentials) == 0:
return
credential = user.credentials[0]
if not (
credential.auth_provider_type == self.type
and credential.auth_provider_id == self.id
):
# Not mine, return
return
# Audit log the user creation
_LOGGER.info(
"User was created for first OIDC sign in: %s from subject %s",
user.id,
credential.data["sub"],
)
# If person creation is enabled, add a person for this user
if self.create_persons:
user_meta = await self.async_user_meta_for_credentials(credential)
await self.async_create_person(user, user_meta.name)
async def async_create_person(self, user: User, name: str) -> None:
"""Create a person for the user."""
_LOGGER.info("Automatically creating person for new user %s", user.id)
# Create a person for the user
try:
await person.async_create_person(
hass=self.hass,
name=name,
user_id=user.id,
)
# Catch all, we don't want to fail here
# pylint: disable=broad-exception-caught
except Exception:
_LOGGER.warning(
"Requested automatic person creation, but person creation failed."
)
# pylint: enable=broad-exception-caught
# ====
# Required functions for Home Assistant Auth Providers
# ====
@@ -99,13 +209,43 @@ class OpenIDAuthProvider(AuthProvider):
self, flow_result: dict[str, str]
) -> Credentials:
"""Get credentials based on the flow result."""
username = flow_result["username"]
sub = flow_result["sub"]
meta = self._user_meta.get(sub)
# Audit logging for the login that is about to occur
_LOGGER.info(
"Logged in user through OIDC: %s, %s", meta["sub"], meta["display_name"]
)
# Iterate over previously created credentials to find one with the same sub
for credential in await self.async_credentials():
if credential.data["username"] == username:
# When logging in again, use the subject to check if the credential exist
# OpenID spec says that sub is the only claim we can rely on, as username
# might change over time.
if credential.data.get("sub") == sub:
return credential
# Create new credentials.
return self.async_create_credentials({"username": username})
# If no credential was found, create a new one
# Username cannot be supplied here as it won't be shown by Home Assistant regardless
# Source: homeassistant/components/config/auth.py, line 162
credential = self.async_create_credentials({"sub": sub})
# If we have user linking enabled, try to link the user here
if self.user_linking:
user = await self._async_find_user_by_username(meta["username"])
if user is not None:
_LOGGER.info(
"User already exists, adding credential for "
+ "OIDC to existing user with username '%s'.",
meta["username"],
)
# Link the credential to the existing user
# Will set the credential isNew = false
await self.store.async_link_user(user, credential)
# If the credential is new, HA will automatically create a new user for us
return credential
async def async_user_meta_for_credentials(
self, credentials: Credentials
@@ -114,15 +254,19 @@ class OpenIDAuthProvider(AuthProvider):
Currently, supports name, is_active, group and local_only.
"""
meta = self._user_meta.get(credentials.data["username"], {})
sub = credentials.data["sub"]
meta = self._user_meta.get(sub, {})
groups = meta.get("groups", [])
# TODO: Allow setting which group is for admins
group = "system-admin" if "admins" in groups else "system-users"
return UserMeta(
name=meta.get("name"),
name=meta.get("display_name"),
is_active=True,
group=group,
local_only="true",
local_only=False,
)
@@ -130,12 +274,11 @@ class OpenIdLoginFlow(LoginFlow):
"""Handler for the login flow."""
async def _finalize_user(self, code: str) -> AuthFlowResult:
username = await self._auth_provider.async_retrieve_username(code)
if username:
_LOGGER.info("Logged in user: %s", username)
sub = await self._auth_provider.async_get_subject(code)
if sub:
return await self.async_finish(
{
"username": username,
"sub": sub,
}
)