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:
committed by
GitHub
parent
ca83e86acb
commit
db4c6bcade
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user