"""OIDC Login Helper and its constituent functions.""" import asyncio import base64 import hashlib import json import logging import secrets import ssl from typing import Any, TypedDict from urllib.parse import parse_qs, urlparse import aiohttp from ..exceptions import CannotConnect, InvalidAuth from ..helpers import create_cookie_jar _LOGGER = logging.getLogger(__name__) async def async_auth_oidc( username: str, password: str, base_url: str, tenant_id: str, policy: str, client_id: str, redirect_uri: str, scope_auth: str, scope_access: str, self_asserted_endpoint: str, policy_confirm_endpoint: str, ) -> str | None: """Perform the login process and return an access token.""" ssl_context = await asyncio.get_running_loop().run_in_executor(None, ssl.create_default_context) connector = aiohttp.TCPConnector(ssl=ssl_context) secure_session = aiohttp.ClientSession(connector=connector, cookie_jar=create_cookie_jar()) try: code_verifier = _generate_code_verifier() code_challenge = _generate_code_challenge(code_verifier) _LOGGER.debug("Generated PKCE code verifier and challenge") config = await _get_config(secure_session, base_url, tenant_id, policy) _LOGGER.debug("Retrieved OAuth configuration") auth_code = await _get_auth( secure_session, config, code_challenge, username, password, client_id, redirect_uri, scope_auth, policy, self_asserted_endpoint, policy_confirm_endpoint, ) if auth_code is None: _LOGGER.error("Failed to obtain authorization code") raise CannotConnect("Failed to obtain authorization code") _LOGGER.debug("Obtained authorization code") tokens = await _get_access( secure_session, config, auth_code, code_verifier, client_id, redirect_uri, scope_access, ) if tokens and "access_token" in tokens: _LOGGER.debug("Successfully obtained access token") return tokens["access_token"] _LOGGER.error("Failed to obtain access token") raise CannotConnect("Failed to obtain access token") except aiohttp.ClientError as err: _LOGGER.exception("Connection error during login") raise CannotConnect(f"Connection error: {err}") from err finally: await secure_session.close() class ConfigDict(TypedDict): """Dictionary to store configuration details for OAuth.""" authorization_endpoint: str issuer: str token_endpoint: str class TokenDict(TypedDict): """Dictionary to store OAuth tokens.""" access_token: str def _generate_code_verifier() -> str: """Generate a code verifier for PKCE.""" return secrets.token_urlsafe(32) def _generate_code_challenge(code_verifier: str) -> str: """Generate a code challenge for PKCE.""" code_challenge_digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() return base64.urlsafe_b64encode(code_challenge_digest).decode("utf-8").rstrip("=") async def _get_config(session: aiohttp.ClientSession, base_url: str, tenant_id: str, policy: str) -> ConfigDict: """Get the configuration from the server.""" config_url = f"{base_url}/{tenant_id}/{policy}/v2.0/.well-known/openid-configuration" _LOGGER.debug("Fetching OAuth configuration from: %s", config_url) config_text, _, status = await _fetch(session, config_url) if status != 200 or not config_text: _LOGGER.error("Failed to get configuration. Status: %s", status) raise CannotConnect("Failed to get configuration") config: ConfigDict = json.loads(config_text) return config async def _get_auth( session: aiohttp.ClientSession, config: ConfigDict, code_challenge: str, username: str, password: str, client_id: str, redirect_uri: str, scope_auth: str, policy: str, self_asserted_endpoint: str, policy_confirm_endpoint: str, ) -> str | None: """Get the authorization code.""" auth_params = { "client_id": client_id, "response_type": "code", "redirect_uri": redirect_uri, "scope": scope_auth, "code_challenge": code_challenge, "code_challenge_method": "S256", } _LOGGER.debug("Requesting authorization code") auth_content, final_url, status = await _fetch(session, config["authorization_endpoint"], params=auth_params) if status != 200 or not auth_content: _LOGGER.error("Failed to get authorization. Status: %s", status) raise CannotConnect("Failed to get authorization") settings = _extract_settings(auth_content) if not settings: _LOGGER.debug("No settings extracted, checking for direct authorization code") if final_url and final_url.startswith(redirect_uri): query = urlparse(final_url).query parsed_query = parse_qs(query) return parsed_query.get("code", [None])[0] return None _LOGGER.debug("Posting credentials") await _post_credentials( session, config["issuer"], settings, username, password, policy, self_asserted_endpoint, ) _LOGGER.debug("Confirming sign-in") return await _confirm_signin(session, config["issuer"], settings, policy, policy_confirm_endpoint) async def _get_access( session: aiohttp.ClientSession, config: ConfigDict, auth_code: str, code_verifier: str, client_id: str, redirect_uri: str, scope_access: str, ) -> TokenDict | None: """Get the access token.""" token_data = { "client_id": client_id, "grant_type": "authorization_code", "code": auth_code, "redirect_uri": redirect_uri, "code_verifier": code_verifier, "scope": scope_access, } _LOGGER.debug("Requesting access token") token_content, _, status = await _fetch(session, config["token_endpoint"], method="POST", data=token_data) if status != 200 or not token_content: _LOGGER.error("Failed to get access token. Status: %s", status) raise CannotConnect("Failed to get access token") tokens: TokenDict = json.loads(token_content) return tokens async def _fetch(session: aiohttp.ClientSession, url: str, **kwargs: Any) -> tuple[str | None, str | None, int]: """Fetch data from a URL.""" method = kwargs.pop("method", "GET") timeout = aiohttp.ClientTimeout(total=30) try: _LOGGER.debug("Fetching URL: %s, Method: %s", url, method) async with session.request(method, url, timeout=timeout, **kwargs) as response: content = await response.text() _LOGGER.debug("Fetch completed. Status: %s", response.status) return content, str(response.url), response.status except aiohttp.ClientError: _LOGGER.exception("Network error occurred") return None, None, 0 def _extract_settings(auth_content: str) -> dict[str, Any] | None: """Extract settings from the authorization content.""" _LOGGER.debug("Extracting settings from authorization content") settings_start = auth_content.find("var SETTINGS = ") if settings_start == -1: _LOGGER.debug("Settings not found in authorization content") return None settings_end = auth_content.find(";", settings_start) if settings_end == -1: _LOGGER.debug("End of settings not found in authorization content") return None settings_json = auth_content[settings_start + 15 : settings_end].strip() try: settings: dict[str, Any] = json.loads(settings_json) except json.JSONDecodeError: _LOGGER.exception("Failed to parse settings JSON") return None else: _LOGGER.debug("Settings successfully extracted") return settings async def _post_credentials( session: aiohttp.ClientSession, issuer: str, settings: dict[str, Any], username: str, password: str, policy: str, self_asserted_endpoint: str, ) -> None: """Post credentials to the server.""" base_url = issuer.rsplit("/", 2)[0] _LOGGER.debug("Posting credentials to %s", base_url) _, _, status = await _fetch( session, f"{base_url}/{policy}/{self_asserted_endpoint}", method="POST", data={ "tx": settings["transId"], "p": policy, "request_type": "RESPONSE", "signInName": username, "password": password, }, headers={"X-CSRF-TOKEN": settings["csrf"]}, ) if status != 200: _LOGGER.error("Failed to post credentials. Status: %s", status) raise InvalidAuth("Invalid username or password") _LOGGER.debug("Credentials posted successfully") async def _confirm_signin( session: aiohttp.ClientSession, issuer: str, settings: dict[str, Any], policy: str, policy_confirm_endpoint: str, ) -> str | None: """Confirm the sign-in process.""" base_url = issuer.rsplit("/", 2)[0] _LOGGER.debug("Confirming sign-in at %s", base_url) _, final_url, status = await _fetch( session, f"{base_url}/{policy}/{policy_confirm_endpoint}", params={ "rememberMe": "false", "csrf_token": settings["csrf"], "tx": settings["transId"], "p": policy, }, allow_redirects=True, ) if status != 200: _LOGGER.error("Failed to confirm signin. Status: %s", status) if status == 403: raise InvalidAuth("Invalid username or password") raise CannotConnect("Failed to confirm signin") if final_url: query = urlparse(final_url).query parsed_query = parse_qs(query) auth_code = parsed_query.get("code", [None])[0] if auth_code: _LOGGER.debug("Sign-in confirmed, authorization code obtained") elif "error" in parsed_query: _LOGGER.error( "Sign-in failed with error: %s, %s", parsed_query.get("error"), parsed_query.get("error_description"), ) raise InvalidAuth("Sign-in failed") else: _LOGGER.warning("Sign-in confirmed, but no authorization code found") return auth_code _LOGGER.warning("Sign-in confirmation did not result in a final URL") return None