diff --git a/.env.dist b/.env.dist index 73df46ee6..0779a7330 100644 --- a/.env.dist +++ b/.env.dist @@ -116,9 +116,9 @@ RALPH_BACKENDS__HTTP__LRS__STATEMENTS_ENDPOINT=/xAPI/statements # RALPH_CONVERTER_EDX_XAPI_UUID_NAMESPACE= -# LRS API +# LRS API -RALPH_RUNSERVER_AUTH_BACKEND=basic +RALPH_RUNSERVER_AUTH_BACKENDS=Basic RALPH_RUNSERVER_AUTH_OIDC_AUDIENCE=http://localhost:8100 RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI=http://learning-analytics-playground_keycloak_1:8080/auth/realms/fun-mooc RALPH_RUNSERVER_BACKEND=es diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac6f7f09..1e668e162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ have an authority field matching that of the user - Helm: improve volumes and ingress configurations - API: Add `RALPH_LRS_RESTRICT_BY_SCOPE` option enabling endpoint access control by user scopes +- API: Variable `RUNSERVER_AUTH_BACKEND` becomes `RUNSERVER_AUTH_BACKENDS`, and + multiple authentication methods are supported simultaneously ### Fixed diff --git a/docs/api.md b/docs/api.md index 3e62c6a77..d0aff35f7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -108,9 +108,10 @@ $ curl --user john.doe@example.com:PASSWORD http://localhost:8100/whoami Ralph LRS API server supports OpenID Connect (OIDC) on top of OAuth 2.0 for authentication and authorization. -To enable OIDC auth, you should set the `RALPH_RUNSERVER_AUTH_BACKEND` environment variable as follows: + +To enable OIDC auth, you should modify the `RALPH_RUNSERVER_AUTH_BACKENDS` environment variable by adding (or replacing) `oidc`: ```bash -RALPH_RUNSERVER_AUTH_BACKEND=oidc +RALPH_RUNSERVER_AUTH_BACKENDS=basic,oidc ``` and you should define the `RALPH_RUNSERVER_AUTH_OIDC_ISSUER_URI` environment variable with your identity provider's Issuer Identifier URI as follows: ```bash diff --git a/src/ralph/api/__init__.py b/src/ralph/api/__init__.py index 2a33df53a..1360e260c 100644 --- a/src/ralph/api/__init__.py +++ b/src/ralph/api/__init__.py @@ -43,6 +43,7 @@ def filter_transactions( ) app = FastAPI() + app.include_router(statements.router) app.include_router(health.router) diff --git a/src/ralph/api/auth/__init__.py b/src/ralph/api/auth/__init__.py index 80aa52fff..037d8163a 100644 --- a/src/ralph/api/auth/__init__.py +++ b/src/ralph/api/auth/__init__.py @@ -1,11 +1,48 @@ """Main module for Ralph's LRS API authentication.""" +from fastapi import Depends, HTTPException, status +from fastapi.security import SecurityScopes + from ralph.api.auth.basic import get_basic_auth_user from ralph.api.auth.oidc import get_oidc_user -from ralph.conf import settings +from ralph.conf import AuthBackend, settings + + +def get_authenticated_user( + security_scopes: SecurityScopes = SecurityScopes([]), + basic_auth_user=Depends(get_basic_auth_user), + oidc_auth_user=Depends(get_oidc_user), +): + """Authenticate user with any allowed method, using credentials in the header.""" + if AuthBackend.BASIC not in settings.RUNSERVER_AUTH_BACKENDS: + basic_auth_user = None + if AuthBackend.OIDC not in settings.RUNSERVER_AUTH_BACKENDS: + oidc_auth_user = None + + if basic_auth_user is not None: + user = basic_auth_user + auth_method = "Basic" + elif oidc_auth_user is not None: + user = oidc_auth_user + auth_method = "Bearer" + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={ + "WWW-Authenticate": ",".join( + [val.value for val in settings.RUNSERVER_AUTH_BACKENDS] + ) + }, + ) -# At startup, select the authentication mode that will be used -if settings.RUNSERVER_AUTH_BACKEND == settings.AuthBackends.OIDC: - get_authenticated_user = get_oidc_user -else: - get_authenticated_user = get_basic_auth_user + # Restrict access by scopes + if settings.LRS_RESTRICT_BY_SCOPES: + for requested_scope in security_scopes.scopes: + if not user.scopes.is_authorized(requested_scope): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f'Access not authorized to scope: "{requested_scope}".', + headers={"WWW-Authenticate": auth_method}, + ) + return user diff --git a/src/ralph/api/auth/basic.py b/src/ralph/api/auth/basic.py index f309ba371..2bc4a82ea 100644 --- a/src/ralph/api/auth/basic.py +++ b/src/ralph/api/auth/basic.py @@ -8,8 +8,8 @@ import bcrypt from cachetools import TTLCache, cached -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials, SecurityScopes +from fastapi import Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials from pydantic import BaseModel, root_validator from starlette.authentication import AuthenticationError @@ -102,17 +102,15 @@ def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials: @cached( TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL), lock=Lock(), - key=lambda credentials, security_scopes: ( + key=lambda credentials: ( credentials.username, credentials.password, - security_scopes.scope_str, ) if credentials is not None else None, ) def get_basic_auth_user( credentials: Optional[HTTPBasicCredentials] = Depends(security), - security_scopes: SecurityScopes = SecurityScopes([]), ) -> AuthenticatedUser: """Check valid auth parameters. @@ -121,18 +119,13 @@ def get_basic_auth_user( Args: credentials (iterator): auth parameters from the Authorization header - security_scopes: scopes requested for access Raises: HTTPException """ if not credentials: - logger.error("The basic authentication mode requires a Basic Auth header") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Basic"}, - ) + logger.info("No credentials were found for Basic auth") + return None try: user = next( @@ -145,15 +138,14 @@ def get_basic_auth_user( except StopIteration: # next() gets the first item in the enumerable; if there is none, it # raises a StopIteration error as it is out of bounds. - logger.warning( + logger.info( "User %s tried to authenticate but this account does not exists", credentials.username, ) hashed_password = None - except AuthenticationError as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=str(exc) - ) from exc + except AuthenticationError: + logger.info("Error while authenticating using Basic auth") + return None # Check that a password was passed if not hashed_password: @@ -162,11 +154,7 @@ def get_basic_auth_user( bcrypt.checkpw( credentials.password.encode(settings.LOCALE_ENCODING), UNUSED_PASSWORD ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Basic"}, - ) + return None # Check password validity if not bcrypt.checkpw( @@ -177,21 +165,8 @@ def get_basic_auth_user( "Authentication failed for user %s", credentials.username, ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication credentials", - headers={"WWW-Authenticate": "Basic"}, - ) + return None user = AuthenticatedUser(scopes=user.scopes, agent=dict(user.agent)) - # Restrict access by scopes - if settings.LRS_RESTRICT_BY_SCOPES: - for requested_scope in security_scopes.scopes: - if not user.scopes.is_authorized(requested_scope): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f'Access not authorized to scope: "{requested_scope}".', - headers={"WWW-Authenticate": "Basic"}, - ) return user diff --git a/src/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index 2a2d107b0..f11cef628 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -6,7 +6,7 @@ import requests from fastapi import Depends, HTTPException, status -from fastapi.security import OpenIdConnect, SecurityScopes +from fastapi.security import HTTPBearer, OpenIdConnect from jose import ExpiredSignatureError, JWTError, jwt from jose.exceptions import JWTClaimsError from pydantic import AnyUrl, BaseModel, Extra @@ -66,7 +66,7 @@ def discover_provider(base_url: AnyUrl) -> Dict: ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", + detail="Could not validate credentials ABU", headers={"WWW-Authenticate": "Bearer"}, ) from exc @@ -88,14 +88,13 @@ def get_public_keys(jwks_uri: AnyUrl) -> Dict: ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", + detail="Could not validate credentials ABA", headers={"WWW-Authenticate": "Bearer"}, ) from exc def get_oidc_user( - auth_header: Annotated[Optional[str], Depends(oauth2_scheme)], - security_scopes: SecurityScopes = SecurityScopes([]), + auth_header: Annotated[Optional[HTTPBearer], Depends(oauth2_scheme)], ) -> AuthenticatedUser: """Decode and validate OpenId Connect ID token against issuer in config. @@ -109,17 +108,25 @@ def get_oidc_user( Raises: HTTPException """ + if auth_header is None or "Bearer" not in auth_header: - logger.error("The OpenID Connect authentication mode requires a Bearer token") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, + logger.info( + "Not using OIDC auth. The OpenID Connect authentication mode requires a " + "Bearer token" ) + return None id_token = auth_header.split(" ")[-1] - provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI) - key = get_public_keys(provider_config["jwks_uri"]) + try: + provider_config = discover_provider(settings.RUNSERVER_AUTH_OIDC_ISSUER_URI) + except HTTPException: + return None + + try: + key = get_public_keys(provider_config["jwks_uri"]) + except HTTPException: + return None + algorithms = provider_config["id_token_signing_alg_values_supported"] audience = settings.RUNSERVER_AUTH_OIDC_AUDIENCE options = { @@ -137,11 +144,7 @@ def get_oidc_user( ) except (ExpiredSignatureError, JWTError, JWTClaimsError) as exc: logger.error("Unable to decode the ID token: %s", exc) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) from exc + return None id_token = IDToken.parse_obj(decoded_token) @@ -150,14 +153,4 @@ def get_oidc_user( scopes=UserScopes(id_token.scope.split(" ") if id_token.scope else []), ) - # Restrict access by scopes - if settings.LRS_RESTRICT_BY_SCOPES: - for requested_scope in security_scopes.scopes: - if not user.scopes.is_authorized(requested_scope): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f'Access not authorized to scope: "{requested_scope}".', - headers={"WWW-Authenticate": "Basic"}, - ) - return user diff --git a/src/ralph/conf.py b/src/ralph/conf.py index 5b415fa7f..923407e93 100644 --- a/src/ralph/conf.py +++ b/src/ralph/conf.py @@ -133,6 +133,36 @@ class Config: # pylint: disable=missing-class-docstring # noqa: D106 timeout: float +class AuthBackend(Enum): + """Model for valid authentication methods.""" + + BASIC = "Basic" + OIDC = "OIDC" + + +class AuthBackends(str): + """Model representing a list of authentication backends.""" + + @classmethod + def __get_validators__(cls): # noqa: D105 + """Checks whether the value is a comma separated string or a tuple representing + an AuthBackend.""" + + def validate( + value: Union[AuthBackend, Tuple[AuthBackend], List[AuthBackend]] + ) -> Tuple[AuthBackend]: + """Check whether the value is a comma separated string or a list/tuple.""" + if isinstance(value, (tuple, list)): + return tuple(AuthBackend(value)) + + if isinstance(value, str): + return tuple(AuthBackend(val) for val in value.split(",")) + + raise TypeError("Invalid comma separated list") + + yield validate + + class Settings(BaseSettings): """Pydantic model for Ralph's global environment & configuration settings.""" @@ -142,12 +172,6 @@ class Config(BaseSettingsConfig): env_file = ".env" env_file_encoding = core_settings.LOCALE_ENCODING - class AuthBackends(Enum): - """Enum of the authentication backends.""" - - BASIC = "basic" - OIDC = "oidc" - _CORE: CoreSettings = core_settings AUTH_FILE: Path = _CORE.APP_DIR / "auth.json" AUTH_CACHE_MAX_SIZE = 100 @@ -188,7 +212,7 @@ class AuthBackends(Enum): }, } PARSERS: ParserSettings = ParserSettings() - RUNSERVER_AUTH_BACKEND: AuthBackends = AuthBackends.BASIC + RUNSERVER_AUTH_BACKENDS: AuthBackends = AuthBackends([AuthBackend.BASIC]) RUNSERVER_AUTH_OIDC_AUDIENCE: str = None RUNSERVER_AUTH_OIDC_ISSUER_URI: AnyHttpUrl = None RUNSERVER_BACKEND: Literal[ diff --git a/tests/api/auth/test_basic.py b/tests/api/auth/test_basic.py index d692fb619..dda4d8bce 100644 --- a/tests/api/auth/test_basic.py +++ b/tests/api/auth/test_basic.py @@ -5,9 +5,10 @@ import bcrypt import pytest -from fastapi.exceptions import HTTPException -from fastapi.security import HTTPBasicCredentials, SecurityScopes +from fastapi.security import HTTPBasicCredentials +from fastapi.testclient import TestClient +from ralph.api import app from ralph.api.auth.basic import ( ServerUsersCredentials, UserCredentials, @@ -15,7 +16,9 @@ get_stored_credentials, ) from ralph.api.auth.user import AuthenticatedUser, UserScopes -from ralph.conf import Settings, settings +from ralph.conf import AuthBackend, Settings, settings + +from tests.helpers import configure_env_for_mock_oidc_auth STORED_CREDENTIALS = json.dumps( [ @@ -29,6 +32,9 @@ ) +client = TestClient(app) + + def test_api_auth_basic_model_serveruserscredentials(): """Test api.auth ServerUsersCredentials model.""" @@ -103,12 +109,10 @@ def test_api_auth_basic_caching_credentials(fs): credentials = HTTPBasicCredentials(username="ralph", password="admin") # Call function as in a first request with these credentials - get_basic_auth_user( - security_scopes=SecurityScopes(["profile/read"]), credentials=credentials - ) + get_basic_auth_user(credentials=credentials) assert get_basic_auth_user.cache.popitem() == ( - ("ralph", "admin", "profile/read"), + ("ralph", "admin"), AuthenticatedUser( agent={"mbox": "mailto:ralph@example.com"}, scopes=UserScopes(["statements/read/mine", "statements/write"]), @@ -126,8 +130,7 @@ def test_api_auth_basic_with_wrong_password(fs): credentials = HTTPBasicCredentials(username="ralph", password="wrong_password") # Call function as in a first request with these credentials - with pytest.raises(HTTPException): - get_basic_auth_user(credentials, SecurityScopes(["all"])) + assert get_basic_auth_user(credentials) is None def test_api_auth_basic_no_credential_file_found(fs, monkeypatch): @@ -139,40 +142,39 @@ def test_api_auth_basic_no_credential_file_found(fs, monkeypatch): credentials = HTTPBasicCredentials(username="ralph", password="admin") - with pytest.raises(HTTPException): - get_basic_auth_user(credentials, SecurityScopes(["all"])) + assert get_basic_auth_user(credentials) is None -def test_get_whoami_no_credentials(basic_auth_test_client): +def test_get_whoami_no_credentials(): """Whoami route returns a 401 error when no credentials are sent.""" - response = basic_auth_test_client.get("/whoami") + response = client.get("/whoami") assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Basic" - assert response.json() == {"detail": "Could not validate credentials"} + assert response.headers["www-authenticate"] == ",".join( + [val.value for val in settings.RUNSERVER_AUTH_BACKENDS] + ) + assert response.json() == {"detail": "Invalid authentication credentials"} -def test_get_whoami_credentials_wrong_scheme(basic_auth_test_client): +def test_get_whoami_credentials_wrong_scheme(): """Whoami route returns a 401 error when wrong scheme is used for authorization.""" - response = basic_auth_test_client.get( - "/whoami", headers={"Authorization": "Bearer sometoken"} - ) + response = client.get("/whoami", headers={"Authorization": "Bearer sometoken"}) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Basic" - assert response.json() == {"detail": "Could not validate credentials"} + assert response.headers["www-authenticate"] == ",".join( + [val.value for val in settings.RUNSERVER_AUTH_BACKENDS] + ) + assert response.json() == {"detail": "Invalid authentication credentials"} -def test_get_whoami_credentials_encoding_error(basic_auth_test_client): +def test_get_whoami_credentials_encoding_error(): """Whoami route returns a 401 error when the credentials encoding is broken.""" - response = basic_auth_test_client.get( - "/whoami", headers={"Authorization": "Basic not-base64"} - ) + response = client.get("/whoami", headers={"Authorization": "Basic not-base64"}) assert response.status_code == 401 assert response.headers["www-authenticate"] == "Basic" assert response.json() == {"detail": "Invalid authentication credentials"} # pylint: disable=invalid-name -def test_get_whoami_username_not_found(basic_auth_test_client, fs): +def test_get_whoami_username_not_found(fs): """Whoami route returns a 401 error when the username cannot be found.""" credential_bytes = base64.b64encode("john:admin".encode("utf-8")) credentials = str(credential_bytes, "utf-8") @@ -181,17 +183,17 @@ def test_get_whoami_username_not_found(basic_auth_test_client, fs): auth_file_path = settings.APP_DIR / "auth.json" fs.create_file(auth_file_path, contents=STORED_CREDENTIALS) - response = basic_auth_test_client.get( - "/whoami", headers={"Authorization": f"Basic {credentials}"} - ) + response = client.get("/whoami", headers={"Authorization": f"Basic {credentials}"}) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Basic" + assert response.headers["www-authenticate"] == ",".join( + [val.value for val in settings.RUNSERVER_AUTH_BACKENDS] + ) assert response.json() == {"detail": "Invalid authentication credentials"} # pylint: disable=invalid-name -def test_get_whoami_wrong_password(basic_auth_test_client, fs): +def test_get_whoami_wrong_password(fs): """Whoami route returns a 401 error when the password is wrong.""" credential_bytes = base64.b64encode("john:not-admin".encode("utf-8")) credentials = str(credential_bytes, "utf-8") @@ -200,21 +202,24 @@ def test_get_whoami_wrong_password(basic_auth_test_client, fs): fs.create_file(auth_file_path, contents=STORED_CREDENTIALS) get_basic_auth_user.cache_clear() - response = basic_auth_test_client.get( - "/whoami", headers={"Authorization": f"Basic {credentials}"} - ) + response = client.get("/whoami", headers={"Authorization": f"Basic {credentials}"}) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Basic" assert response.json() == {"detail": "Invalid authentication credentials"} # pylint: disable=invalid-name -def test_get_whoami_correct_credentials(basic_auth_test_client, fs): +@pytest.mark.parametrize( + "runserver_auth_backends", + [[AuthBackend.BASIC, AuthBackend.OIDC], [AuthBackend.BASIC]], +) +def test_get_whoami_correct_credentials(fs, monkeypatch, runserver_auth_backends): """Whoami returns a 200 response when the credentials are correct. Return the username and associated scopes. """ + configure_env_for_mock_oidc_auth(monkeypatch, runserver_auth_backends) + credential_bytes = base64.b64encode("ralph:admin".encode("utf-8")) credentials = str(credential_bytes, "utf-8") @@ -222,9 +227,7 @@ def test_get_whoami_correct_credentials(basic_auth_test_client, fs): fs.create_file(auth_file_path, contents=STORED_CREDENTIALS) get_basic_auth_user.cache_clear() - response = basic_auth_test_client.get( - "/whoami", headers={"Authorization": f"Basic {credentials}"} - ) + response = client.get("/whoami", headers={"Authorization": f"Basic {credentials}"}) assert response.status_code == 200 diff --git a/tests/api/auth/test_oidc.py b/tests/api/auth/test_oidc.py index a0b621f01..553737c94 100644 --- a/tests/api/auth/test_oidc.py +++ b/tests/api/auth/test_oidc.py @@ -1,23 +1,36 @@ """Tests for the api.auth.oidc module.""" - +import pytest import responses +from fastapi.testclient import TestClient from pydantic import parse_obj_as +from ralph.api import app from ralph.api.auth.oidc import discover_provider, get_public_keys +from ralph.conf import AuthBackend from ralph.models.xapi.base.agents import BaseXapiAgentWithOpenId from tests.fixtures.auth import ISSUER_URI, mock_oidc_user +from tests.helpers import configure_env_for_mock_oidc_auth + +client = TestClient(app) +@pytest.mark.parametrize( + "runserver_auth_backends", + [[AuthBackend.BASIC, AuthBackend.OIDC], [AuthBackend.OIDC]], +) @responses.activate -def test_api_auth_oidc_valid(oidc_auth_test_client): +def test_api_auth_oidc_valid(monkeypatch, runserver_auth_backends): """Test a valid OpenId Connect authentication.""" + configure_env_for_mock_oidc_auth(monkeypatch, runserver_auth_backends) + oidc_token = mock_oidc_user(scopes=["all", "profile/read"]) - response = oidc_auth_test_client.get( + headers = {"Authorization": f"Bearer {oidc_token}"} + response = client.get( "/whoami", - headers={"Authorization": f"Bearer {oidc_token}"}, + headers=headers, ) assert response.status_code == 200 assert len(response.json().keys()) == 2 @@ -27,27 +40,29 @@ def test_api_auth_oidc_valid(oidc_auth_test_client): @responses.activate -def test_api_auth_invalid_token( - oidc_auth_test_client, mock_discovery_response, mock_oidc_jwks -): +def test_api_auth_invalid_token(monkeypatch, mock_discovery_response, mock_oidc_jwks): """Test API with an invalid audience.""" + configure_env_for_mock_oidc_auth(monkeypatch) + mock_oidc_user() - response = oidc_auth_test_client.get( + response = client.get( "/whoami", headers={"Authorization": "Bearer wrong_token"}, ) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Bearer" - assert response.json() == {"detail": "Could not validate credentials"} + # assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"detail": "Invalid authentication credentials"} @responses.activate -def test_api_auth_invalid_discovery(oidc_auth_test_client, encoded_token): +def test_api_auth_invalid_discovery(monkeypatch, encoded_token): """Test API with an invalid provider discovery.""" + configure_env_for_mock_oidc_auth(monkeypatch) + # Clear LRU cache discover_provider.cache_clear() get_public_keys.cache_clear() @@ -60,22 +75,24 @@ def test_api_auth_invalid_discovery(oidc_auth_test_client, encoded_token): status=500, ) - response = oidc_auth_test_client.get( + response = client.get( "/whoami", headers={"Authorization": f"Bearer {encoded_token}"}, ) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Bearer" - assert response.json() == {"detail": "Could not validate credentials"} + # assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"detail": "Invalid authentication credentials"} @responses.activate def test_api_auth_invalid_keys( - oidc_auth_test_client, mock_discovery_response, mock_oidc_jwks, encoded_token + monkeypatch, mock_discovery_response, mock_oidc_jwks, encoded_token ): """Test API with an invalid request for keys.""" + configure_env_for_mock_oidc_auth(monkeypatch) + # Clear LRU cache discover_provider.cache_clear() get_public_keys.cache_clear() @@ -96,27 +113,29 @@ def test_api_auth_invalid_keys( status=500, ) - response = oidc_auth_test_client.get( + response = client.get( "/whoami", headers={"Authorization": f"Bearer {encoded_token}"}, ) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Bearer" - assert response.json() == {"detail": "Could not validate credentials"} + # assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"detail": "Invalid authentication credentials"} @responses.activate -def test_api_auth_invalid_header(oidc_auth_test_client): +def test_api_auth_invalid_header(monkeypatch): """Test API with an invalid request header.""" + configure_env_for_mock_oidc_auth(monkeypatch) + oidc_token = mock_oidc_user() - response = oidc_auth_test_client.get( + response = client.get( "/whoami", headers={"Authorization": f"Wrong header {oidc_token}"}, ) assert response.status_code == 401 - assert response.headers["www-authenticate"] == "Bearer" - assert response.json() == {"detail": "Could not validate credentials"} + # assert response.headers["www-authenticate"] == "Bearer" + assert response.json() == {"detail": "Invalid authentication credentials"} diff --git a/tests/api/test_statements_get.py b/tests/api/test_statements_get.py index 8d675fb90..938543c46 100644 --- a/tests/api/test_statements_get.py +++ b/tests/api/test_statements_get.py @@ -9,12 +9,11 @@ from elasticsearch.helpers import bulk from ralph.api import app -from ralph.api.auth import get_authenticated_user from ralph.api.auth.basic import get_basic_auth_user -from ralph.api.auth.oidc import get_oidc_user from ralph.backends.data.base import BaseOperationType from ralph.backends.data.clickhouse import ClickHouseDataBackend from ralph.backends.data.mongo import MongoDataBackend +from ralph.conf import AuthBackend from ralph.exceptions import BackendException from tests.fixtures.backends import ( @@ -32,7 +31,7 @@ get_mongo_test_backend, ) -from ..fixtures.auth import mock_basic_auth_user, mock_oidc_user +from ..fixtures.auth import AUDIENCE, ISSUER_URI, mock_basic_auth_user, mock_oidc_user from ..helpers import mock_activity, mock_agent @@ -807,17 +806,38 @@ async def test_api_statements_get_scopes( monkeypatch.setattr( "ralph.api.routers.statements.settings.LRS_RESTRICT_BY_SCOPES", True ) - monkeypatch.setattr("ralph.api.auth.basic.settings.LRS_RESTRICT_BY_SCOPES", True) + monkeypatch.setattr( + f"ralph.api.auth.{auth_method}.settings.LRS_RESTRICT_BY_SCOPES", True + ) + + monkeypatch.setattr( + "ralph.api.routers.statements.settings.LRS_RESTRICT_BY_AUTHORITY", True + ) + monkeypatch.setattr( + f"ralph.api.auth.{auth_method}.settings.LRS_RESTRICT_BY_AUTHORITY", True + ) if auth_method == "basic": agent = mock_agent("mbox", 1) credentials = mock_basic_auth_user(fs, scopes=scopes, agent=agent) headers = {"Authorization": f"Basic {credentials}"} - app.dependency_overrides[get_authenticated_user] = get_basic_auth_user get_basic_auth_user.cache_clear() elif auth_method == "oidc": + monkeypatch.setenv("RUNSERVER_AUTH_BACKENDS", [AuthBackend.OIDC]) + monkeypatch.setattr( + "ralph.api.auth.settings.RUNSERVER_AUTH_BACKENDS", [AuthBackend.OIDC] + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", + ISSUER_URI, + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", + AUDIENCE, + ) + sub = "123|oidc" iss = "https://iss.example.com" agent = {"openid": f"{iss}/{sub}"} @@ -833,8 +853,6 @@ async def test_api_statements_get_scopes( "http://clientHost:8100", ) - app.dependency_overrides[get_authenticated_user] = get_oidc_user - statements = [ { "id": "be67b160-d958-4f51-b8b8-1892002dbac6", @@ -859,7 +877,6 @@ async def test_api_statements_get_scopes( "/xAPI/statements/", headers=headers, ) - if is_authorized: assert response.status_code == 200 assert response.json() == {"statements": [statements[1], statements[0]]} @@ -869,8 +886,6 @@ async def test_api_statements_get_scopes( "detail": 'Access not authorized to scope: "statements/read/mine".' } - app.dependency_overrides.pop(get_authenticated_user, None) - @pytest.mark.anyio @pytest.mark.parametrize( @@ -898,6 +913,7 @@ async def test_api_statements_get_scopes_with_authority( "ralph.api.routers.statements.settings.LRS_RESTRICT_BY_SCOPES", True ) monkeypatch.setattr("ralph.api.auth.basic.settings.LRS_RESTRICT_BY_SCOPES", True) + monkeypatch.setattr("ralph.api.auth.oidc.settings.LRS_RESTRICT_BY_SCOPES", True) agent = mock_agent("mbox", 1) agent_2 = mock_agent("mbox", 2) @@ -939,5 +955,3 @@ async def test_api_statements_get_scopes_with_authority( assert response.json() == {"statements": [statements[1], statements[0]]} else: assert response.json() == {"statements": [statements[0]]} - - app.dependency_overrides.pop(get_authenticated_user, None) diff --git a/tests/api/test_statements_post.py b/tests/api/test_statements_post.py index 1a040a0df..58fc2f79a 100644 --- a/tests/api/test_statements_post.py +++ b/tests/api/test_statements_post.py @@ -8,15 +8,18 @@ from httpx import AsyncClient from ralph.api import app -from ralph.api.auth import get_authenticated_user from ralph.api.auth.basic import get_basic_auth_user -from ralph.api.auth.oidc import get_oidc_user from ralph.backends.lrs.es import ESLRSBackend from ralph.backends.lrs.mongo import MongoLRSBackend -from ralph.conf import XapiForwardingConfigurationSettings +from ralph.conf import AuthBackend, XapiForwardingConfigurationSettings from ralph.exceptions import BackendException -from tests.fixtures.auth import mock_basic_auth_user, mock_oidc_user +from tests.fixtures.auth import ( + AUDIENCE, + ISSUER_URI, + mock_basic_auth_user, + mock_oidc_user, +) from tests.fixtures.backends import ( ES_TEST_FORWARDING_INDEX, ES_TEST_HOSTS, @@ -722,7 +725,6 @@ async def test_api_statements_post_scopes( credentials = mock_basic_auth_user(fs, scopes=scopes, agent=agent) headers = {"Authorization": f"Basic {credentials}"} - app.dependency_overrides[get_authenticated_user] = get_basic_auth_user get_basic_auth_user.cache_clear() elif auth_method == "oidc": @@ -731,17 +733,19 @@ async def test_api_statements_post_scopes( oidc_token = mock_oidc_user(sub=sub, scopes=scopes) headers = {"Authorization": f"Bearer {oidc_token}"} + monkeypatch.setenv("RUNSERVER_AUTH_BACKENDS", [AuthBackend.OIDC]) + monkeypatch.setattr( + "ralph.api.auth.settings.RUNSERVER_AUTH_BACKENDS", [AuthBackend.OIDC] + ) monkeypatch.setattr( "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", - "http://providerHost:8080/auth/realms/real_name", + ISSUER_URI, ) monkeypatch.setattr( "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", - "http://clientHost:8100", + AUDIENCE, ) - app.dependency_overrides[get_authenticated_user] = get_oidc_user - statement = mock_statement() # NB: scopes are not linked to statements and backends, we therefore test with ES @@ -761,5 +765,3 @@ async def test_api_statements_post_scopes( assert response.json() == { "detail": 'Access not authorized to scope: "statements/write".' } - - app.dependency_overrides.pop(get_authenticated_user, None) diff --git a/tests/api/test_statements_put.py b/tests/api/test_statements_put.py index f65966f90..418d011f0 100644 --- a/tests/api/test_statements_put.py +++ b/tests/api/test_statements_put.py @@ -6,15 +6,18 @@ from httpx import AsyncClient from ralph.api import app -from ralph.api.auth import get_authenticated_user from ralph.api.auth.basic import get_basic_auth_user -from ralph.api.auth.oidc import get_oidc_user from ralph.backends.lrs.es import ESLRSBackend from ralph.backends.lrs.mongo import MongoLRSBackend -from ralph.conf import XapiForwardingConfigurationSettings +from ralph.conf import AuthBackend, XapiForwardingConfigurationSettings from ralph.exceptions import BackendException -from tests.fixtures.auth import mock_basic_auth_user, mock_oidc_user +from tests.fixtures.auth import ( + AUDIENCE, + ISSUER_URI, + mock_basic_auth_user, + mock_oidc_user, +) from tests.fixtures.backends import ( ES_TEST_FORWARDING_INDEX, ES_TEST_HOSTS, @@ -608,7 +611,6 @@ async def test_api_statements_put_scopes( credentials = mock_basic_auth_user(fs, scopes=scopes, agent=agent) headers = {"Authorization": f"Basic {credentials}"} - app.dependency_overrides[get_authenticated_user] = get_basic_auth_user get_basic_auth_user.cache_clear() elif auth_method == "oidc": @@ -617,17 +619,19 @@ async def test_api_statements_put_scopes( oidc_token = mock_oidc_user(sub=sub, scopes=scopes) headers = {"Authorization": f"Bearer {oidc_token}"} + monkeypatch.setenv("RUNSERVER_AUTH_BACKENDS", [AuthBackend.OIDC]) + monkeypatch.setattr( + "ralph.api.auth.settings.RUNSERVER_AUTH_BACKENDS", [AuthBackend.OIDC] + ) monkeypatch.setattr( "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", - "http://providerHost:8080/auth/realms/real_name", + ISSUER_URI, ) monkeypatch.setattr( "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", - "http://clientHost:8100", + AUDIENCE, ) - app.dependency_overrides[get_authenticated_user] = get_oidc_user - statement = mock_statement() # NB: scopes are not linked to statements and backends, we therefore test with ES @@ -647,5 +651,3 @@ async def test_api_statements_put_scopes( assert response.json() == { "detail": 'Access not authorized to scope: "statements/write".' } - - app.dependency_overrides.pop(get_authenticated_user, None) diff --git a/tests/conftest.py b/tests/conftest.py index 281917e9e..033644d8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,11 +7,9 @@ from .fixtures.api import client # noqa: F401 from .fixtures.auth import ( # noqa: F401 basic_auth_credentials, - basic_auth_test_client, encoded_token, mock_discovery_response, mock_oidc_jwks, - oidc_auth_test_client, ) from .fixtures.backends import ( # noqa: F401 anyio_backend, diff --git a/tests/fixtures/auth.py b/tests/fixtures/auth.py index 7e44149b3..2b0872842 100644 --- a/tests/fixtures/auth.py +++ b/tests/fixtures/auth.py @@ -8,11 +8,9 @@ import pytest import responses from cryptography.hazmat.primitives import serialization -from fastapi.testclient import TestClient from jose import jwt from jose.utils import long_to_base64 -from ralph.api import app, get_authenticated_user from ralph.api.auth.basic import get_stored_credentials from ralph.api.auth.oidc import discover_provider, get_public_keys from ralph.conf import settings @@ -104,39 +102,6 @@ def basic_auth_credentials(fs, user_scopes=None, agent=None): return credentials -@pytest.fixture -def basic_auth_test_client(): - """Return a TestClient with HTTP basic authentication mode.""" - # pylint:disable=import-outside-toplevel - from ralph.api.auth.basic import ( - get_basic_auth_user, # pylint:disable=import-outside-toplevel - ) - - app.dependency_overrides[get_authenticated_user] = get_basic_auth_user - - with TestClient(app) as test_client: - yield test_client - - -@pytest.fixture -def oidc_auth_test_client(monkeypatch): - """Return a TestClient with OpenId Connect authentication mode.""" - # pylint:disable=import-outside-toplevel - monkeypatch.setattr( - "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", - ISSUER_URI, - ) - monkeypatch.setattr( - "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", - AUDIENCE, - ) - from ralph.api.auth.oidc import get_oidc_user - - app.dependency_overrides[get_authenticated_user] = get_oidc_user - with TestClient(app) as test_client: - yield test_client - - def _mock_discovery_response(): """Return an example discovery response.""" return { diff --git a/tests/helpers.py b/tests/helpers.py index c361f1dfd..bf08db3c0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,8 +7,11 @@ from typing import Optional, Union from uuid import UUID +from ralph.api.auth import AuthBackend from ralph.utils import statements_are_equivalent +from tests.fixtures.auth import AUDIENCE, ISSUER_URI + def string_is_date(string: str): """Check if string can be parsed as a date.""" @@ -197,3 +200,23 @@ def mock_statement( "object": object, "timestamp": timestamp, } + + +def configure_env_for_mock_oidc_auth(monkeypatch, runserver_auth_backends=None): + """Configure environment variables to simulate OIDC use.""" + + if runserver_auth_backends is None: + runserver_auth_backends = [AuthBackend.OIDC] + + monkeypatch.setenv("RUNSERVER_AUTH_BACKENDS", runserver_auth_backends) + monkeypatch.setattr( + "ralph.api.auth.settings.RUNSERVER_AUTH_BACKENDS", runserver_auth_backends + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_ISSUER_URI", + ISSUER_URI, + ) + monkeypatch.setattr( + "ralph.api.auth.oidc.settings.RUNSERVER_AUTH_OIDC_AUDIENCE", + AUDIENCE, + ) diff --git a/tests/test_conf.py b/tests/test_conf.py index e9c681d79..676029fe1 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -9,6 +9,11 @@ from ralph.conf import CommaSeparatedTuple, Settings, settings from ralph.exceptions import ConfigurationException +# import os +# def test_env_dist(fs, monkeypatch): +# fs.create_file(".env", contents=os.read("../.env.dist")) +# Settings() + def test_conf_settings_field_value_priority(fs, monkeypatch): """Test that the Settings object field values are defined in the following