diff --git a/bin/pytest b/bin/pytest index 06e71b7d..ee9a65af 100755 --- a/bin/pytest +++ b/bin/pytest @@ -10,4 +10,7 @@ DOCKER_USER="${DOCKER_UID}:${DOCKER_GID}" DOCKER_USER=${DOCKER_USER} \ DOCKER_UID=${DOCKER_UID} \ DOCKER_GID=${DOCKER_GID} \ - docker compose run --rm api pipenv run pytest "$@" + docker compose run --rm \ + -e QUALICHARGE_OIDC_IS_ENABLED=True \ + api \ + pipenv run pytest "$@" diff --git a/env.d/api b/env.d/api index c7d27625..2cd0b10a 100644 --- a/env.d/api +++ b/env.d/api @@ -8,6 +8,7 @@ QUALICHARGE_DB_PORT=5432 QUALICHARGE_DB_USER=qualicharge QUALICHARGE_DEBUG=1 QUALICHARGE_OIDC_PROVIDER_BASE_URL=http://keycloak:8080/realms/qualicharge +QUALICHARGE_OIDC_IS_ENABLED=False QUALICHARGE_OAUTH2_TOKEN_ENCODING_KEY=thisissupersecret QUALICHARGE_OAUTH2_TOKEN_ISSUER=http://localhost:8010 QUALICHARGE_TEST_DB_NAME=test-qualicharge-api diff --git a/src/api/qualicharge/auth/oidc.py b/src/api/qualicharge/auth/oidc.py index 4ab67a5a..e45d1bc5 100644 --- a/src/api/qualicharge/auth/oidc.py +++ b/src/api/qualicharge/auth/oidc.py @@ -33,7 +33,9 @@ # API auth logger logger = logging.getLogger(__name__) -auth_scheme = OAuth2PasswordBearer(tokenUrl=settings.OAUTH2_TOKEN_URL) +auth_scheme: Union[OAuth2PasswordBearer, HTTPBearer] = OAuth2PasswordBearer( + tokenUrl=settings.OAUTH2_TOKEN_URL +) if settings.OIDC_IS_ENABLED: auth_scheme = HTTPBearer() @@ -123,7 +125,7 @@ def get_token( try: decoded_token = jwt.decode( - token=token.credentials if settings.OIDC_IS_ENABLED else token, + token=token.credentials if settings.OIDC_IS_ENABLED else token, # type: ignore[union-attr, arg-type] key=key, algorithms=algorithms, **extra_decode_options, # type: ignore[arg-type] diff --git a/src/api/qualicharge/conf.py b/src/api/qualicharge/conf.py index 90be962f..add5a16e 100644 --- a/src/api/qualicharge/conf.py +++ b/src/api/qualicharge/conf.py @@ -65,7 +65,7 @@ def TEST_DATABASE_URL(self) -> PostgresDsn: ) # OIDC - OIDC_IS_ENABLED: bool = False # If false, fallback to (local) OAuth2 scheme + OIDC_IS_ENABLED: bool = True # If false, fallback to (local) OAuth2 scheme OIDC_PROVIDER_BASE_URL: AnyHttpUrl OIDC_PROVIDER_DISCOVER_TIMEOUT: int = 5 OIDC_CONFIGURATION_PATH: Path = Path("/.well-known/openid-configuration") diff --git a/src/api/tests/api/v1/routers/test_auth.py b/src/api/tests/api/v1/routers/test_auth.py index b255b4db..a03227cd 100644 --- a/src/api/tests/api/v1/routers/test_auth.py +++ b/src/api/tests/api/v1/routers/test_auth.py @@ -7,8 +7,9 @@ from jose import jwt from qualicharge.auth.factories import IDTokenFactory -from qualicharge.auth.models import UserRead +from qualicharge.auth.models import IDToken, UserCreate, UserRead from qualicharge.auth.oidc import discover_provider, get_public_keys +from qualicharge.auth.schemas import User from qualicharge.conf import settings @@ -141,3 +142,94 @@ def test_whoami_jwt_decoding_error( assert response.json() == { "message": "Authentication failed: Unable to decode ID token" } + + +def test_login_with_invalid_user(client): + """Test the login endpoint with invalid user.""" + response = client.post( + "/auth/token", data={"username": "johndoe", "password": "foo"} + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "message": "Authentication failed: Wrong login or password" + } + + +def test_login_with_invalid_password(client, db_session): + """Test the login endpoint with invalid password.""" + user = User( + **UserCreate( + username="johndoe", + password="foo", # noqa: S106 + email="john@doe.com", + first_name="John", + last_name="Doe", + is_superuser=False, + is_staff=False, + is_active=True, + ).model_dump() + ) + db_session.add(user) + + response = client.post( + "/auth/token", data={"username": "johndoe", "password": "bar"} + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "message": "Authentication failed: Wrong login or password" + } + + +def test_login_with_inactive_user(client, db_session): + """Test the login endpoint with an inactive user.""" + user = User( + **UserCreate( + username="johndoe", + password="foo", # noqa: S106 + email="john@doe.com", + first_name="John", + last_name="Doe", + is_superuser=False, + is_staff=False, + is_active=False, + ).model_dump() + ) + db_session.add(user) + + response = client.post( + "/auth/token", data={"username": "johndoe", "password": "foo"} + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == {"message": "Authentication failed: User is not active"} + + +def test_login(client, db_session): + """Test the login endpoint.""" + user = User( + **UserCreate( + username="johndoe", + password="foo", # noqa: S106 + email="john@doe.com", + first_name="John", + last_name="Doe", + is_superuser=False, + is_staff=False, + is_active=True, + ).model_dump() + ) + db_session.add(user) + + response = client.post( + "/auth/token", data={"username": "johndoe", "password": "foo"} + ) + assert response.status_code == status.HTTP_200_OK + token = response.json() + assert token["token_type"] == "bearer" # noqa: S105 + decoded = jwt.decode( + token=token["access_token"], + key=settings.OAUTH2_TOKEN_ENCODING_KEY, + audience=settings.OIDC_EXPECTED_AUDIENCE, + ) + id_token = IDToken(**decoded) + assert id_token.sub == "johndoe" + assert id_token.email == "john@doe.com" diff --git a/src/api/tests/auth/test_oidc.py b/src/api/tests/auth/test_oidc.py index a241956e..93da77c3 100644 --- a/src/api/tests/auth/test_oidc.py +++ b/src/api/tests/auth/test_oidc.py @@ -1,6 +1,7 @@ """Tests for qualicharge.auth.oidc module.""" from datetime import datetime +from typing import Union import httpx import pytest @@ -92,65 +93,86 @@ def test_get_public_keys_with_bad_configuration(httpx_mock): get_public_keys("http://oidc/wrong") -def test_get_token(httpx_mock, monkeypatch, id_token_factory: IDTokenFactory): +@pytest.mark.parametrize("oidc_is_enabled", (True, False)) +def test_get_token( + oidc_is_enabled, httpx_mock, monkeypatch, id_token_factory: IDTokenFactory +): """Test the OIDC get token utility.""" monkeypatch.setenv("QUALICHARGE_OIDC_PROVIDER_BASE_URL", "http://oidc") - httpx_mock.add_response( - method="GET", - url=str(settings.OIDC_CONFIGURATION_URL), - json={ - "jwks_uri": "https://oidc/certs", - "id_token_signing_alg_values_supported": "HS256", - }, - ) - httpx_mock.add_response( - method="GET", - url="https://oidc/certs", - json=[ - "secret", - ], - ) + monkeypatch.setattr(settings, "OIDC_IS_ENABLED", oidc_is_enabled) + monkeypatch.setattr(settings, "OAUTH2_TOKEN_ENCODING_KEY", "secret") + + if oidc_is_enabled: + httpx_mock.add_response( + method="GET", + url=str(settings.OIDC_CONFIGURATION_URL), + json={ + "jwks_uri": "https://oidc/certs", + "id_token_signing_alg_values_supported": "HS256", + }, + ) + httpx_mock.add_response( + method="GET", + url="https://oidc/certs", + json=[ + "secret", + ], + ) - bearer_token = HTTPAuthorizationCredentials( - scheme="Bearer", - credentials=jwt.encode( - claims=id_token_factory.build().model_dump(), key="secret" - ), + token = jwt.encode( + claims=id_token_factory.build().model_dump(), + key="secret", ) - token = get_token(security_scopes=SecurityScopes(), token=bearer_token) - assert token.email == "john@doe.com" + bearer_token: Union[str, HTTPAuthorizationCredentials] = token + if oidc_is_enabled: + bearer_token = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=token, + ) + + id_token = get_token(security_scopes=SecurityScopes(), token=bearer_token) + assert id_token.email == "john@doe.com" +@pytest.mark.parametrize("oidc_is_enabled", (True, False)) def test_get_token_with_expired_token( - httpx_mock, monkeypatch, id_token_factory: IDTokenFactory + oidc_is_enabled, httpx_mock, monkeypatch, id_token_factory: IDTokenFactory ): """Test the OIDC get token utility when the token expired.""" monkeypatch.setenv("QUALICHARGE_OIDC_PROVIDER_BASE_URL", "http://oidc") - httpx_mock.add_response( - method="GET", - url=str(settings.OIDC_CONFIGURATION_URL), - json={ - "jwks_uri": "https://oidc/certs", - "id_token_signing_alg_values_supported": "HS256", - }, - ) - httpx_mock.add_response( - method="GET", - url="https://oidc/certs", - json=[ - "secret", - ], - ) + monkeypatch.setattr(settings, "OIDC_IS_ENABLED", oidc_is_enabled) + monkeypatch.setattr(settings, "OAUTH2_TOKEN_ENCODING_KEY", "secret") + + if oidc_is_enabled: + httpx_mock.add_response( + method="GET", + url=str(settings.OIDC_CONFIGURATION_URL), + json={ + "jwks_uri": "https://oidc/certs", + "id_token_signing_alg_values_supported": "HS256", + }, + ) + httpx_mock.add_response( + method="GET", + url="https://oidc/certs", + json=[ + "secret", + ], + ) # As exp should be set to iat + 300, the token should be expired iat = int(datetime.now().timestamp()) - 500 - bearer_token = HTTPAuthorizationCredentials( - scheme="Bearer", - credentials=jwt.encode( - claims=id_token_factory.build(iat=iat).model_dump(), - key="secret", - ), + token = jwt.encode( + claims=id_token_factory.build(iat=iat).model_dump(), + key="secret", ) + bearer_token: Union[str, HTTPAuthorizationCredentials] = token + if oidc_is_enabled: + bearer_token = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=token, + ) + with pytest.raises(OIDCAuthenticationError, match="Token signature expired"): get_token(security_scopes=SecurityScopes(), token=bearer_token) diff --git a/src/api/tests/fixtures/app.py b/src/api/tests/fixtures/app.py index 1712fa41..ba945e79 100644 --- a/src/api/tests/fixtures/app.py +++ b/src/api/tests/fixtures/app.py @@ -8,6 +8,7 @@ from qualicharge.auth.factories import GroupFactory, IDTokenFactory, UserFactory from qualicharge.auth.oidc import get_token from qualicharge.auth.schemas import UserGroup +from qualicharge.conf import settings @pytest.fixture @@ -17,7 +18,9 @@ def client(): @pytest.fixture -def client_auth(request, id_token_factory: IDTokenFactory, db_session: Session): +def client_auth( + request, id_token_factory: IDTokenFactory, db_session: Session, monkeypatch +): """An authenticated test client configured for the /api/v1 application. Parameter: @@ -33,6 +36,8 @@ def client_auth(request, id_token_factory: IDTokenFactory, db_session: Session): GroupFactory.__session__ = db_session UserFactory.__session__ = db_session + monkeypatch.setattr(settings, "OIDC_IS_ENABLED", True) + persist = True fields = { "email": "john@doe.com",