diff --git a/gso/__init__.py b/gso/__init__.py index 307827996c61b8e639428cbf9798abea341ff98b..0227c19d7d29838dc2952ac34abb13d6a9ebc3ea 100644 --- a/gso/__init__.py +++ b/gso/__init__.py @@ -1,5 +1,7 @@ """The main entrypoint for :term:`GSO`, and the different ways in which it can be run.""" +from gso import monkeypatches # noqa: F401, isort:skip + import typer from orchestrator import OrchestratorCore, app_settings from orchestrator.cli.main import app as cli_app diff --git a/gso/api/v1/imports.py b/gso/api/v1/imports.py index 03eb49596e6da0aeaa4f87ec0ba72e513c6d07a8..684d7b7eb43bd878b5d3919a768f7a70ab33172c 100644 --- a/gso/api/v1/imports.py +++ b/gso/api/v1/imports.py @@ -6,10 +6,10 @@ from uuid import UUID from fastapi import Depends, HTTPException, status from fastapi.routing import APIRouter -from orchestrator.security import opa_security_default from orchestrator.services import processes from pydantic import BaseModel, root_validator, validator +from gso.auth.security import opa_security_default from gso.products.product_blocks.iptrunk import IptrunkType, PhyPortCapacity from gso.products.product_blocks.router import RouterRole, RouterVendor from gso.products.product_blocks.site import SiteTier diff --git a/gso/api/v1/subscriptions.py b/gso/api/v1/subscriptions.py index 438e40885a9c4d6d82543434563f8fdf029ae65d..24c9307f1ce67371ea3f89c6f0888380ad7ea09c 100644 --- a/gso/api/v1/subscriptions.py +++ b/gso/api/v1/subscriptions.py @@ -6,9 +6,9 @@ from fastapi import Depends, status from fastapi.routing import APIRouter from orchestrator.domain import SubscriptionModel from orchestrator.schemas import SubscriptionDomainModelSchema -from orchestrator.security import opa_security_default from orchestrator.services.subscriptions import build_extended_domain_model +from gso.auth.security import opa_security_default from gso.services.subscriptions import get_active_router_subscriptions router = APIRouter( diff --git a/gso/auth/__init__.py b/gso/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d87d539d946d6583659c37e7c7fd024ca5ceb31c --- /dev/null +++ b/gso/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication and authorization integration for OAuth2, OIDC, and OPA.""" diff --git a/gso/auth/oidc_policy_helper.py b/gso/auth/oidc_policy_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..d9219cddabb8d0538cd7e484d9ea9d3941d0f037 --- /dev/null +++ b/gso/auth/oidc_policy_helper.py @@ -0,0 +1,443 @@ +"""OpenID Connect and Open Policy Agent Integration for GSO Application. + +This module provides helper functions and classes for handling OpenID Connect (OIDC) and +Open Policy Agent (OPA) related functionalities within the GSO application. It includes +implementations for OIDC-based user authentication and user information modeling. Additionally, +it facilitates making authorization decisions based on policies defined in OPA. Key components +comprise OIDCUser, OIDCUserModel, OPAResult, and opa_decision. These elements integrate with +FastAPI to ensure secure API development. +""" + +import re +import ssl +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping +from http import HTTPStatus +from json import JSONDecodeError +from typing import Any, ClassVar, cast + +from fastapi.exceptions import HTTPException +from fastapi.param_functions import Depends +from fastapi.requests import Request +from fastapi.security.http import HTTPBearer +from httpx import AsyncClient, NetworkError +from pydantic import BaseModel +from starlette.requests import ClientDisconnect +from structlog import get_logger + +from gso.auth.settings import oauth2lib_settings + +logger = get_logger(__name__) + +HTTPX_SSL_CONTEXT = ssl.create_default_context() # https://github.com/encode/httpx/issues/838 + + +class InvalidScopeValueError(ValueError): + """Exception raised for invalid scope values in OIDC.""" + + +class OIDCUserModel(dict): + """The standard claims of a OIDCUserModel object. Defined per `Section 5.1`_ and AAI attributes. + + .. _`Section 5.1`: http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + """ + + #: registered claims that OIDCUserModel supports + REGISTERED_CLAIMS: ClassVar[list[str]] = [ + "sub", + "name", + "given_name", + "family_name", + "middle_name", + "nickname", + "preferred_username", + "profile", + "picture", + "website", + "email", + "email_verified", + "gender", + "birthdate", + "zoneinfo", + "locale", + "phone_number", + "phone_number_verified", + "address", + "updated_at", + ] + + def __getattr__(self, key: str) -> Any: + """Get an attribute value using key. + + Overrides the default behavior to return the value from the dictionary + if the attribute is one of the registered claims or raises an AttributeError + if the key is not found. + + Args: + ---- + key: The attribute name to retrieve. + + Returns: + ------- + The value of the attribute if it exists, otherwise raises AttributeError. + """ + try: + return object.__getattribute__(self, key) + except AttributeError as error: + if key in self.REGISTERED_CLAIMS: + return self.get(key) + raise error from None + + @property + def user_name(self) -> str: + """Return the username of the user.""" + if "user_name" in self.keys(): + return cast(str, self["user_name"]) + if "unspecified_id" in self.keys(): + return cast(str, self["unspecified_id"]) + return "" + + @property + def display_name(self) -> str: + """Return the display name of the user.""" + return self.get("display_name", "") + + @property + def principal_name(self) -> str: + """Return the principal name of the user.""" + return self.get("eduperson_principal_name", "") + + @property + def scopes(self) -> set[str]: + """Return the scopes of the user.""" + scope_value = self.get("scope") + if scope_value is None: + return set() + + if isinstance(scope_value, list): + return {item for item in scope_value if isinstance(item, str)} + if isinstance(scope_value, str): + return set(filter(None, re.split("[ ,]", scope_value))) + + message = f"Invalid scope value: {scope_value}" + raise InvalidScopeValueError(message) + + +async def _make_async_client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient(http1=True, verify=HTTPX_SSL_CONTEXT) as client: + yield client + + +class OIDCConfig(BaseModel): + """Configuration for OpenID Connect (OIDC) authentication and token validation.""" + + issuer: str + authorization_endpoint: str + token_endpoint: str + userinfo_endpoint: str + introspect_endpoint: str | None = None + introspection_endpoint: str | None = None + jwks_uri: str + response_types_supported: list[str] + response_modes_supported: list[str] + grant_types_supported: list[str] + subject_types_supported: list[str] + id_token_signing_alg_values_supported: list[str] + scopes_supported: list[str] + token_endpoint_auth_methods_supported: list[str] + claims_supported: list[str] + claims_parameter_supported: bool + request_parameter_supported: bool + code_challenge_methods_supported: list[str] + + +class OPAResult(BaseModel): + """Represents the outcome of an authorization decision made by the Open Policy Agent (OPA). + + Attributes + ---------- + - result (bool): Indicates whether the access request is allowed or denied. + - decision_id (str): A unique identifier for the decision made by OPA. + """ + + result: bool = False + decision_id: str + + +class OIDCUser(HTTPBearer): + """OIDCUser class extends the :term:`HTTPBearer` class to do extra verification. + + The class will act as follows: + 1. Validate the Credentials at AAI proxy by calling the UserInfo endpoint + """ + + openid_config: OIDCConfig | None = None + openid_url: str + resource_server_id: str + resource_server_secret: str + + def __init__( + self, + openid_url: str, + resource_server_id: str, + resource_server_secret: str, + *, + auto_error: bool = True, + scheme_name: str | None = None, + ): + """Set up OIDCUser with specified OpenID Connect configurations and credentials.""" + super().__init__(auto_error=auto_error) + self.openid_url = openid_url + self.resource_server_id = resource_server_id + self.resource_server_secret = resource_server_secret + self.scheme_name = scheme_name or self.__class__.__name__ + + async def __call__( # type: ignore[override] + self, request: Request, token: str | None = None + ) -> OIDCUserModel | None: + """Return the OIDC user from OIDC introspect endpoint. + + This is used as a security module in Fastapi projects + + Args: + ---- + request: Starlette request method. + token: Optional value to directly pass a token. + + Returns: + ------- + OIDCUserModel object. + + """ + if not oauth2lib_settings.OAUTH2_ACTIVE: + return None + + async with AsyncClient(http1=True, verify=HTTPX_SSL_CONTEXT) as async_request: + await self.check_openid_config(async_request) + + if not token: + credentials = await super().__call__(request) + if not credentials: + return None + token = credentials.credentials + + intercepted_token = await self.introspect_token(async_request, token) + + if "active" not in intercepted_token: + logger.error("Token doesn't have the mandatory 'active' key, probably caused by a caching problem") + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="Missing active key") + if not intercepted_token.get("active", False): + logger.info("User is not active", url=request.url, user_info=intercepted_token) + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User is not active") + + user_info = await self.userinfo(async_request, token) + + logger.debug("OIDCUserModel object.", intercepted_token=intercepted_token) + return user_info + + async def check_openid_config(self, async_request: AsyncClient) -> None: + """Check of openid config is loaded and load if not.""" + if self.openid_config is not None: + return + + response = await async_request.get(self.openid_url + "/.well-known/openid-configuration") + self.openid_config = OIDCConfig.parse_obj(response.json()) + + async def userinfo(self, async_request: AsyncClient, token: str) -> OIDCUserModel: + """Get the userinfo from the openid server. + + Args: + ---- + async_request: The async request + token: the access_token + + Returns: + ------- + OIDCUserModel from openid server + + """ + await self.check_openid_config(async_request) + assert self.openid_config, "OpenID config should be loaded" # noqa: S101 + + response = await async_request.post( + self.openid_config.userinfo_endpoint, + data={"token": token}, + headers={"Authorization": f"Bearer {token}"}, + ) + try: + data = dict(response.json()) + except JSONDecodeError as err: + logger.debug( + "Unable to parse userinfo response", + detail=response.text, + resource_server_id=self.resource_server_id, + openid_url=self.openid_url, + ) + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=response.text) from err + logger.debug("Response from openid userinfo", response=data) + + if response.status_code not in range(200, 300): + logger.debug( + "Userinfo cannot find an active token, user unauthorized", + detail=response.text, + resource_server_id=self.resource_server_id, + openid_url=self.openid_url, + ) + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=response.text) + + return OIDCUserModel(data) + + async def introspect_token(self, async_request: AsyncClient, token: str) -> dict: + """Introspect the access token to see if it is a valid token. + + Args: + ---- + async_request: The async request + token: the access_token + + Returns: + ------- + dict from openid server + + """ + await self.check_openid_config(async_request) + assert self.openid_config, "OpenID config should be loaded" # noqa: S101 + + endpoint = self.openid_config.introspect_endpoint or self.openid_config.introspection_endpoint or "" + response = await async_request.post( + endpoint, + data={"token": token, "client_id": self.resource_server_id}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + try: + data = dict(response.json()) + except JSONDecodeError as err: + logger.debug( + "Unable to parse introspect response", + detail=response.text, + resource_server_id=self.resource_server_id, + openid_url=self.openid_url, + ) + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=response.text) from err + + logger.debug("Response from openid introspect", response=data) + + if response.status_code not in range(200, 300): + logger.debug( + "Introspect cannot find an active token, user unauthorized", + detail=response.text, + resource_server_id=self.resource_server_id, + openid_url=self.openid_url, + ) + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=response.text) + + return data + + +async def _get_decision(async_request: AsyncClient, opa_url: str, opa_input: dict) -> OPAResult: + logger.debug("Posting input json to Policy agent", opa_url=opa_url, input=opa_input) + try: + response = await async_request.post(opa_url, json=opa_input) + except (NetworkError, TypeError) as exc: + logger.debug("Could not get decision from policy agent", error=str(exc)) + raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="Policy agent is unavailable") from exc + + result = response.json() + logger.debug("Received response from Policy agent", response=result) + return OPAResult(result=result["result"]["allow"], decision_id=result["decision_id"]) + + +def _evaluate_decision(decision: OPAResult, *, auto_error: bool, **context: dict[str, Any]) -> bool: + did = decision.decision_id + + if decision.result: + logger.debug("User is authorized to access the resource", decision_id=did, **context) + return True + + logger.debug("User is not allowed to access the resource", decision_id=did, **context) + if not auto_error: + return False + + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail=f"User is not allowed to access resource: {context.get('resource')} Decision was taken with id: {did}", + ) + + +def opa_decision( + opa_url: str, + oidc_security: OIDCUser, + *, + auto_error: bool = True, + opa_kwargs: Mapping[str, str] | None = None, +) -> Callable[[Request, OIDCUserModel, AsyncClient], Awaitable[bool | None]]: + """Create a decision function for Open Policy Agent (OPA) authorization checks. + + This function generates an asynchronous decision function that can be used in FastAPI endpoints + to authorize requests based on OPA policies. It utilizes OIDC for user information and makes a + call to the OPA service to determine authorization. + + Args: + ---- + opa_url: URL of the Open Policy Agent service. + oidc_security: An instance of OIDCUser for user authentication. + auto_error: If True, automatically raises an HTTPException on authorization failure. + opa_kwargs: Additional keyword arguments to be passed to the OPA input. + + Returns: + ------- + An asynchronous decision function that can be used as a dependency in FastAPI endpoints. + """ + + async def _opa_decision( + request: Request, + user_info: OIDCUserModel = Depends(oidc_security), # noqa: B008 + async_request: AsyncClient = Depends(_make_async_client), # noqa: B008 + ) -> bool | None: + """Check OIDCUserModel against the OPA policy. + + This is used as a security module in Fastapi projects + This method will make an async call towards the Policy agent. + + Args: + ---- + request: Request object that will be used to retrieve request metadata. + user_info: The OIDCUserModel object that will be checked + async_request: The :term:`httpx` client. + """ + if not (oauth2lib_settings.OAUTH2_ACTIVE and oauth2lib_settings.OAUTH2_AUTHORIZATION_ACTIVE): + return None + + try: + json = await request.json() + # Silencing the Decode error or Type error when request.json() does not return anything sane. + # Some requests do not have a json response therefore as this code gets called on every request + # we need to suppress the `None` case (TypeError) or the `other than json` case (JSONDecodeError) + # Suppress AttributeError in case of websocket request, it doesn't have .json + except (JSONDecodeError, TypeError, ClientDisconnect, AttributeError): + json = {} + + # defaulting to GET request method for WebSocket request, it doesn't have .method + request_method = request.method if hasattr(request, "method") else "GET" + opa_input = { + "input": { + **(opa_kwargs or {}), + **user_info, + "resource": request.url.path, + "method": request_method, + "arguments": {"path": request.path_params, "query": {**request.query_params}, "json": json}, + } + } + + decision = await _get_decision(async_request, opa_url, opa_input) + + context = { + "resource": opa_input["input"]["resource"], + "method": opa_input["input"]["method"], + "user_info": user_info, + "input": opa_input, + "url": request.url, + } + return _evaluate_decision(decision, auto_error=auto_error, **context) + + return _opa_decision diff --git a/gso/auth/security.py b/gso/auth/security.py new file mode 100644 index 0000000000000000000000000000000000000000..16065e467e02176d92df20563c4c3e0f56845667 --- /dev/null +++ b/gso/auth/security.py @@ -0,0 +1,41 @@ +"""Module for initializing OAuth client credentials and OIDC user.""" + +from authlib.integrations.starlette_client import OAuth +from nwastdlib.url import URL + +from gso.auth.oidc_policy_helper import HTTPX_SSL_CONTEXT, OIDCUser, opa_decision +from gso.auth.settings import oauth2_settings + +oauth_client_credentials = OAuth() + +well_known_endpoint = URL(oauth2_settings.OIDC_CONF_WELL_KNOWN_URL) + +oauth_client_credentials.register( + "connext", + server_metadata_url=well_known_endpoint / ".well-known" / "openid-configuration", + client_id=oauth2_settings.OAUTH2_RESOURCE_SERVER_ID, + client_secret=oauth2_settings.OAUTH2_RESOURCE_SERVER_SECRET, + request_token_params={"grant_type": "client_credentials"}, + client_kwargs={"verify": HTTPX_SSL_CONTEXT}, +) + +oidc_user = OIDCUser( + oauth2_settings.OIDC_CONF_WELL_KNOWN_URL, + oauth2_settings.OAUTH2_RESOURCE_SERVER_ID, + oauth2_settings.OAUTH2_RESOURCE_SERVER_SECRET, +) + +opa_security_default = opa_decision(oauth2_settings.OPA_URL, oidc_user) + + +def get_oidc_user() -> OIDCUser: + """Retrieve the global OIDCUser instance. + + This function returns the instance of OIDCUser initialized in the module. + It is typically used for accessing the OIDCUser across different parts of the application. + + Returns + ------- + OIDCUser: The instance of OIDCUser configured with OAuth2 settings. + """ + return oidc_user diff --git a/gso/auth/settings.py b/gso/auth/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..d8b281253f3548efb6d5a6f87c03329f496ec114 --- /dev/null +++ b/gso/auth/settings.py @@ -0,0 +1,41 @@ +"""Security configurations and utilities for the GSO application. Handles OAuth2 and OpenID Connect. + +authentication and authorization, including token validation and user authentication. Integrates +with external authentication providers for enhanced security management. + +Todo: +---- +Remove token and sensitive data from OPA console and API. + +""" + +from pydantic import BaseSettings, Field + + +class Oauth2LibSettings(BaseSettings): + """Common settings for applications depending on oauth2.""" + + ENVIRONMENT: str = "local" + SERVICE_NAME: str = "" + MUTATIONS_ENABLED: bool = False + ENVIRONMENT_IGNORE_MUTATION_DISABLED: list[str] = Field( + default_factory=list, description="Environments for which to allow unauthenticated mutations" + ) + OAUTH2_ACTIVE: bool = True + OAUTH2_AUTHORIZATION_ACTIVE: bool = True + + +oauth2lib_settings = Oauth2LibSettings() + + +class Oauth2Settings(BaseSettings): + """Configuration settings for OAuth2 and OpenID Connect (OIDC).""" + + OAUTH2_RESOURCE_SERVER_ID: str = "" + OAUTH2_RESOURCE_SERVER_SECRET: str = "" + OAUTH2_TOKEN_URL: str = "" + OIDC_CONF_WELL_KNOWN_URL: str = "" + OPA_URL: str = "http://localhost:8181/v1/data/gap/gso/api/access" + + +oauth2_settings = Oauth2Settings() diff --git a/gso/monkeypatches.py b/gso/monkeypatches.py new file mode 100644 index 0000000000000000000000000000000000000000..2e94f50bdd27288e4ce7d829036ffbc8f022ef20 --- /dev/null +++ b/gso/monkeypatches.py @@ -0,0 +1,17 @@ +"""Override certain classes and settings in the oauth2_lib.fastapi package with custom implementations. + +This adjustment is typically done to extend or modify the functionality of the original +oauth2_lib package to meet specific requirements of the gso application. +""" + +import oauth2_lib.fastapi +import oauth2_lib.settings + +from gso.auth.oidc_policy_helper import HTTPX_SSL_CONTEXT, OIDCUser, OIDCUserModel, opa_decision +from gso.auth.settings import oauth2lib_settings + +oauth2_lib.fastapi.OIDCUser = OIDCUser # type: ignore[assignment, misc] +oauth2_lib.fastapi.OIDCUserModel = OIDCUserModel # type: ignore[assignment, misc] +oauth2_lib.fastapi.opa_decision = opa_decision # type: ignore[assignment] +oauth2_lib.fastapi.HTTPX_SSL_CONTEXT = HTTPX_SSL_CONTEXT +oauth2_lib.settings.oauth2lib_settings = oauth2lib_settings # type: ignore[assignment] diff --git a/pyproject.toml b/pyproject.toml index 27544d313cbf350daba1ab147e844e4ade062689..e345710ba1f602942d24b792649ea61c0d4f5ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,8 @@ ignore = [ "N805", "PLR0913", "PLR0904", - "PLW1514" + "PLW1514", + "S106", ] line-length = 120 select = [ diff --git a/requirements.txt b/requirements.txt index ca077d368e511aeb84a585f696abd685697eedcf..87dc706a5986c5833967ea7fae184a5228dad1ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ ruff==0.1.5 sphinx==7.2.6 sphinx-rtd-theme==1.3.0 urllib3_mock==0.3.3 +pytest-asyncio==0.23.3 diff --git a/test/auth/__init__.py b/test/auth/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/test/auth/test_oidc_policy_helper.py b/test/auth/test_oidc_policy_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..a47e2d0fc714ec08b2fee8b8a4d247c1bc950c72 --- /dev/null +++ b/test/auth/test_oidc_policy_helper.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +import pytest +from fastapi import HTTPException, Request +from httpx import AsyncClient, NetworkError, Response + +from gso.auth.oidc_policy_helper import ( + OIDCConfig, + OIDCUser, + OIDCUserModel, + OPAResult, + _evaluate_decision, + _get_decision, + opa_decision, +) +from gso.auth.settings import oauth2lib_settings + + +@pytest.fixture(scope="module", autouse=True) +def _enable_oath2(_database, db_uri): + oauth2lib_settings.OAUTH2_ACTIVE = True + + yield + + oauth2lib_settings.OAUTH2_ACTIVE = False + + +@pytest.fixture() +def mock_openid_config(): + return { + "issuer": "https://example.proxy.aai.geant.org", + "authorization_endpoint": "https://example.proxy.aai.geant.org/auth", + "token_endpoint": "https://example.proxy.aai.geant.org/token", + "userinfo_endpoint": "https://example.proxy.aai.geant.org/userinfo", + "introspect_endpoint": "https://example.proxy.aai.geant.org/introspect", + "jwks_uri": "https://example.proxy.aai.geant.org/jwks", + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + "grant_types_supported": ["authorization_code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid"], + "token_endpoint_auth_methods_supported": ["client_secret_basic"], + "claims_supported": ["sub", "name", "email"], + "claims_parameter_supported": True, + "request_parameter_supported": True, + "code_challenge_methods_supported": ["S256"], + } + + +@pytest.fixture() +def oidc_user(mock_openid_config): + user = OIDCUser( + openid_url="https://example.proxy.aai.geant.org", + resource_server_id="resource_server", + resource_server_secret="secret", + ) + user.openid_config = OIDCConfig.parse_obj(mock_openid_config) + return user + + +@pytest.fixture() +def mock_request(): + request = Mock(spec=Request) + request.method = "GET" + request.url.path = "/some/path" + request.json = AsyncMock(return_value={"key": "value"}) + request.path_params = {} + request.query_params = {} + request.headers.get = Mock(return_value="Bearer testtoken1212121") + return request + + +@pytest.fixture() +def mock_oidc_user(): + oidc_user = AsyncMock( + OIDCUser, openid_url="https://example.com", resource_server_id="test", resource_server_secret="secret" + ) + oidc_user.__call__ = AsyncMock(return_value=OIDCUserModel({"sub": "123", "name": "John Doe"})) + return oidc_user + + +@pytest.fixture() +def mock_async_client(): + return AsyncClient(verify=False) # noqa: S501 + + +@pytest.mark.asyncio() +async def test_introspect_token_success(oidc_user, mock_async_client): + mock_response_data = {"active": True, "sub": "123"} + mock_async_client.post = AsyncMock(return_value=Response(200, json=mock_response_data)) + + result = await oidc_user.introspect_token(mock_async_client, "test_token") + + assert result == mock_response_data + + +@pytest.mark.asyncio() +async def test_introspect_token_json_decode_error(oidc_user, mock_async_client): + mock_async_client.post = AsyncMock(return_value=Response(200, content=b"not a json")) + + with pytest.raises(HTTPException) as exc_info: + await oidc_user.introspect_token(mock_async_client, "test_token") + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio() +async def test_introspect_token_http_error(oidc_user, mock_async_client): + mock_async_client.post = AsyncMock(return_value=Response(400, json={"error": "invalid_request"})) + + with pytest.raises(HTTPException) as exc_info: + await oidc_user.introspect_token(mock_async_client, "test_token") + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio() +async def test_introspect_token_unauthorized(oidc_user, mock_async_client): + mock_async_client.post = AsyncMock(return_value=Response(401, json={"detail": "Invalid token"})) + + with pytest.raises(HTTPException) as exc_info: + await oidc_user.introspect_token(mock_async_client, "test_token") + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + assert "Invalid token" in str(exc_info.value.detail) + + +@pytest.mark.asyncio() +async def test_userinfo_success(oidc_user, mock_async_client): + mock_response = {"sub": "1234", "name": "John Doe", "email": "johndoe@example.com"} + mock_async_client.post = AsyncMock(return_value=Response(200, json=mock_response)) + + response = await oidc_user.userinfo(mock_async_client, "test_token") + + assert isinstance(response, OIDCUserModel) + assert response["sub"] == "1234" + assert response["name"] == "John Doe" + assert response["email"] == "johndoe@example.com" + + +@pytest.mark.asyncio() +async def test_opa_decision_success(mock_request, mock_async_client): + mock_user_info = OIDCUserModel({"sub": "123", "name": "John Doe", "email": "johndoe@example.com"}) + + mock_oidc_user = AsyncMock(spec=OIDCUser) + mock_oidc_user.return_value = AsyncMock(return_value=mock_user_info) + + with patch( + "gso.auth.oidc_policy_helper._get_decision", + return_value=AsyncMock(return_value=OPAResult(result=True, decision_id="1234")), + ): + decision_function = opa_decision("http://mock-opa-url", oidc_security=mock_oidc_user) + + result = await decision_function(mock_request, mock_user_info, mock_async_client) + + assert result is True + + +@pytest.mark.asyncio() +async def test_userinfo_unauthorized(oidc_user, mock_async_client): + mock_async_client.post = AsyncMock(return_value=Response(401, json={"detail": "Invalid token"})) + + with pytest.raises(HTTPException) as exc_info: + await oidc_user.userinfo(mock_async_client, "test_token") + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + assert "Invalid token" in str(exc_info.value.detail) + + +@pytest.mark.asyncio() +async def test_userinfo_json_decode_error(oidc_user, mock_async_client): + mock_async_client.post = AsyncMock(return_value=Response(200, text="not a json")) + + with pytest.raises(HTTPException) as exc_info: + await oidc_user.userinfo(mock_async_client, "test_token") + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + + +@pytest.mark.asyncio() +async def test_get_decision_success(mock_async_client): + mock_async_client.post = AsyncMock( + return_value=Response(200, json={"result": {"allow": True}, "decision_id": "123"}) + ) + + opa_url = "http://mock-opa-url" + opa_input = {"some_input": "value"} + decision = await _get_decision(mock_async_client, opa_url, opa_input) + + assert decision.result is True + assert decision.decision_id == "123" + + +@pytest.mark.asyncio() +async def test_get_decision_network_error(mock_async_client): + mock_async_client.post = AsyncMock(side_effect=NetworkError("Network error")) + + opa_url = "http://mock-opa-url" + opa_input = {"some_input": "value"} + + with pytest.raises(HTTPException) as exc_info: + await _get_decision(mock_async_client, opa_url, opa_input) + + assert exc_info.value.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert exc_info.value.detail == "Policy agent is unavailable" + + +def test_evaluate_decision_allow(): + decision = OPAResult(result=True, decision_id="123") + result = _evaluate_decision(decision, auto_error=True) + + assert result is True + + +def test_evaluate_decision_deny_without_auto_error(): + decision = OPAResult(result=False, decision_id="123") + result = _evaluate_decision(decision, auto_error=False) + + assert result is False + + +def test_evaluate_decision_deny_with_auto_error(): + decision = OPAResult(result=False, decision_id="123") + + with pytest.raises(HTTPException) as exc_info: + _evaluate_decision(decision, auto_error=True) + + assert exc_info.value.status_code == HTTPStatus.FORBIDDEN + assert "Decision was taken with id: 123" in str(exc_info.value.detail) + + +@pytest.mark.asyncio() +async def test_oidc_user_call_with_token(oidc_user, mock_request, mock_async_client): + oidc_user.introspect_token = AsyncMock(return_value={"active": True}) + oidc_user.userinfo = AsyncMock(return_value=OIDCUserModel({"sub": "123", "name": "John Doe"})) + + result = await oidc_user.__call__(mock_request, token="test_token") + + assert isinstance(result, OIDCUserModel) + assert result["sub"] == "123" + assert result["name"] == "John Doe" + + +@pytest.mark.asyncio() +async def test_oidc_user_call_inactive_token(oidc_user, mock_request, mock_async_client): + oidc_user.introspect_token = AsyncMock(return_value={"active": False}) + + with pytest.raises(HTTPException) as exc_info: + await oidc_user.__call__(mock_request, token="test_token") + + assert exc_info.value.status_code == HTTPStatus.UNAUTHORIZED + assert "User is not active" in str(exc_info.value.detail) + + +@pytest.mark.asyncio() +async def test_oidc_user_call_no_token(oidc_user, mock_request): + with ( + patch("fastapi.security.http.HTTPBearer.__call__", return_value=None), + patch("httpx.AsyncClient.post", new_callable=MagicMock) as mock_post, + patch("httpx.AsyncClient.get", new_callable=MagicMock) as mock_get, + ): + mock_post.return_value = MagicMock(status_code=200, json=lambda: {"active": False}) + mock_get.return_value = MagicMock(status_code=200, json=lambda: {}) + + result = await oidc_user.__call__(mock_request) + + assert result is None + + +@pytest.mark.asyncio() +async def test_oidc_user_call_token_from_request(oidc_user, mock_request, mock_async_client): + mock_request.state.credentials = Mock() + mock_request.state.credentials.credentials = "request_token" + + oidc_user.introspect_token = AsyncMock(return_value={"active": True}) + oidc_user.userinfo = AsyncMock(return_value=OIDCUserModel({"sub": "123", "name": "John Doe"})) + + result = await oidc_user.__call__(mock_request) + + assert isinstance(result, OIDCUserModel) + assert result["sub"] == "123" + assert result["name"] == "John Doe" diff --git a/test/conftest.py b/test/conftest.py index 7d53941eb84cdcf806ce3efa34985570b269973a..779fc39d0f50addb3875baa4a0836adc071f8d86 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -12,7 +12,6 @@ from alembic import command from alembic.config import Config from faker import Faker from faker.providers import BaseProvider -from oauth2_lib.settings import oauth2lib_settings from orchestrator import app_settings from orchestrator.db import Database, db from orchestrator.db.database import ENGINE_ARGUMENTS, SESSION_ARGUMENTS, BaseModel @@ -22,6 +21,7 @@ from sqlalchemy.engine import make_url from sqlalchemy.orm import scoped_session, sessionmaker from starlette.testclient import TestClient +from gso.auth.settings import oauth2lib_settings from gso.main import init_gso_app from gso.utils.helpers import LAGMember