From 20ffbc07feebf7ad631691fac13881dd6a0b57f2 Mon Sep 17 00:00:00 2001 From: inimaz <93inigo93@gmail.com> Date: Sun, 7 Dec 2025 12:12:05 +0100 Subject: [PATCH] feat: allow any authentication provider --- .../carbonserver/api/routers/authenticate.py | 36 ++--- .../services/auth_providers/auth_provider.py | 106 +++++++++++++++ .../auth_providers/auth_provider_factory.py | 68 ++++++++++ .../auth_providers/fief_auth_provider.py | 127 +++++++++++++++++ .../auth_providers/no_auth_provider.py | 128 ++++++++++++++++++ .../carbonserver/api/services/auth_service.py | 50 +++---- carbonserver/carbonserver/config.py | 5 + carbonserver/carbonserver/container.py | 8 ++ carbonserver/main.py | 2 + .../tests/api/service/test_auth_provider.py | 89 ++++++++++++ 10 files changed, 572 insertions(+), 47 deletions(-) create mode 100644 carbonserver/carbonserver/api/services/auth_providers/auth_provider.py create mode 100644 carbonserver/carbonserver/api/services/auth_providers/auth_provider_factory.py create mode 100644 carbonserver/carbonserver/api/services/auth_providers/fief_auth_provider.py create mode 100644 carbonserver/carbonserver/api/services/auth_providers/no_auth_provider.py create mode 100644 carbonserver/tests/api/service/test_auth_provider.py diff --git a/carbonserver/carbonserver/api/routers/authenticate.py b/carbonserver/carbonserver/api/routers/authenticate.py index 1e9eb5f55..66146b66f 100644 --- a/carbonserver/carbonserver/api/routers/authenticate.py +++ b/carbonserver/carbonserver/api/routers/authenticate.py @@ -7,8 +7,8 @@ from dependency_injector.wiring import Provide, inject from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import RedirectResponse -from fief_client import FiefAsync +from carbonserver.api.services.auth_providers.auth_provider import AuthProvider from carbonserver.api.services.auth_service import ( OptionalUserWithAuthDependency, UserWithAuthDependency, @@ -24,16 +24,13 @@ router = APIRouter() -fief = FiefAsync( - settings.fief_url, settings.fief_client_id, settings.fief_client_secret -) - @router.get("/auth/check", name="auth-check") @inject def check_login( auth_user: UserWithAuthDependency = Depends(OptionalUserWithAuthDependency), sign_up_service: SignUpService = Depends(Provide[ServerContainer.sign_up_service]), + auth_provider: AuthProvider = Depends(Provide[ServerContainer.auth_provider]), ): """ return user data or redirect to login screen @@ -44,9 +41,15 @@ def check_login( @router.get("/auth/auth-callback", name="auth_callback") -async def auth_callback(request: Request, response: Response, code: str = Query(...)): +@inject +async def auth_callback( + request: Request, + response: Response, + code: str = Query(...), + auth_provider: AuthProvider = Depends(Provide[ServerContainer.auth_provider]), +): redirect_uri = request.url_for("auth_callback") - tokens, _ = await fief.auth_callback(code, redirect_uri) + tokens, _ = await auth_provider.handle_auth_callback(code, str(redirect_uri)) response = RedirectResponse(request.url_for("auth-user")) response.set_cookie( SESSION_COOKIE_NAME, @@ -65,6 +68,7 @@ async def get_login( state: Optional[str] = None, code: Optional[str] = None, sign_up_service: SignUpService = Depends(Provide[ServerContainer.sign_up_service]), + auth_provider: AuthProvider = Depends(Provide[ServerContainer.auth_provider]), ): """ login and redirect to frontend app with token @@ -72,14 +76,15 @@ async def get_login( login_url = request.url_for("login") if code: + client_id, client_secret = auth_provider.get_client_credentials() res = requests.post( - f"{settings.fief_url}/api/token", + auth_provider.get_token_endpoint(), data={ "grant_type": "authorization_code", "code": code, "redirect_uri": login_url, - "client_id": settings.fief_client_id, - "client_secret": settings.fief_client_secret, + "client_id": client_id, + "client_secret": client_secret, }, ) @@ -87,11 +92,8 @@ async def get_login( if "id_token" not in res.json(): if "access_token" not in res.json(): return Response(content="Invalid code", status_code=400) - # get profile data from fief server if not present in response - id_token = requests.get( - settings.fief_url + "/api/userinfo", - headers={"Authorization": "Bearer " + res.json()["access_token"]}, - ).json() + # get profile data from auth provider if not present in response + id_token = await auth_provider.get_user_info(res.json()["access_token"]) sign_up_service.check_jwt_user(id_token) else: sign_up_service.check_jwt_user(res.json()["id_token"], create=True) @@ -123,5 +125,7 @@ async def get_login( return response state = str(int(random.random() * 1000)) - url = f"{settings.fief_url}/authorize?response_type=code&client_id={settings.fief_client_id}&redirect_uri={login_url}&scope={' '.join(OAUTH_SCOPES)}&state={state}" + client_id, _ = auth_provider.get_client_credentials() + authorize_url = auth_provider.get_authorize_endpoint() + url = f"{authorize_url}?response_type=code&client_id={client_id}&redirect_uri={login_url}&scope={' '.join(OAUTH_SCOPES)}&state={state}" return RedirectResponse(url=url) diff --git a/carbonserver/carbonserver/api/services/auth_providers/auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/auth_provider.py new file mode 100644 index 000000000..11f75663f --- /dev/null +++ b/carbonserver/carbonserver/api/services/auth_providers/auth_provider.py @@ -0,0 +1,106 @@ +""" +Authentication Provider Interface + +This module defines an abstract interface for authentication providers. +To implement a custom authentication provider, create a class that inherits +from AuthProvider and implements all the required methods. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple + + +class AuthProvider(ABC): + """ + Abstract base class for authentication providers. + + This interface allows CodeCarbon to support multiple authentication providers + (Fief, Auth0, Keycloak, custom OAuth2, etc.) by implementing this interface. + """ + + @abstractmethod + async def get_auth_url( + self, redirect_uri: str, scope: List[str], state: Optional[str] = None + ) -> str: + """ + Generate the authorization URL for the OAuth2 flow. + + Args: + redirect_uri: The URI to redirect to after authentication + scope: List of OAuth2 scopes to request + state: Optional state parameter for CSRF protection + + Returns: + The authorization URL to redirect the user to + """ + + @abstractmethod + async def handle_auth_callback( + self, code: str, redirect_uri: str + ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + """ + Handle the OAuth2 callback and exchange the code for tokens. + + Args: + code: The authorization code from the OAuth2 provider + redirect_uri: The redirect URI used in the initial auth request + + Returns: + A tuple of (tokens, user_info) where: + - tokens: Dict containing access_token, refresh_token, expires_in, etc. + - user_info: Optional dict containing user information + """ + + @abstractmethod + async def validate_access_token(self, token: str) -> bool: + """ + Validate an access token. + + Args: + token: The access token to validate + + Returns: + True if the token is valid, False otherwise + + Raises: + Exception if validation fails + """ + + @abstractmethod + async def get_user_info(self, access_token: str) -> Dict[str, Any]: + """ + Get user information from the authentication provider. + + Args: + access_token: The access token for the user + + Returns: + Dict containing user information (sub, email, name, etc.) + """ + + @abstractmethod + def get_token_endpoint(self) -> str: + """ + Get the token endpoint URL for the provider. + + Returns: + The token endpoint URL + """ + + @abstractmethod + def get_authorize_endpoint(self) -> str: + """ + Get the authorization endpoint URL for the provider. + + Returns: + The authorization endpoint URL + """ + + @abstractmethod + def get_client_credentials(self) -> Tuple[str, str]: + """ + Get the client ID and client secret. + + Returns: + A tuple of (client_id, client_secret) + """ diff --git a/carbonserver/carbonserver/api/services/auth_providers/auth_provider_factory.py b/carbonserver/carbonserver/api/services/auth_providers/auth_provider_factory.py new file mode 100644 index 000000000..11136d023 --- /dev/null +++ b/carbonserver/carbonserver/api/services/auth_providers/auth_provider_factory.py @@ -0,0 +1,68 @@ +""" +Authentication Provider Factory + +This module provides a factory function to create the appropriate authentication +provider based on configuration settings. +""" + +from typing import Optional + +from carbonserver.api.services.auth_providers.auth_provider import AuthProvider +from carbonserver.api.services.auth_providers.fief_auth_provider import FiefAuthProvider +from carbonserver.api.services.auth_providers.no_auth_provider import NoAuthProvider +from carbonserver.config import settings + + +def create_auth_provider(provider_name: Optional[str] = None) -> AuthProvider: + """ + Factory function to create an authentication provider based on configuration. + + Args: + provider_name: Optional provider name override. If not provided, + uses the AUTH_PROVIDER setting from config. + + Returns: + An instance of AuthProvider + + Raises: + ValueError: If the provider name is not recognized + + Example: + ```python + # Using default from settings + provider = create_auth_provider() + + # Override provider + provider = create_auth_provider("none") + ``` + """ + provider_type = provider_name or settings.auth_provider + provider_type = provider_type.lower() + + if provider_type == "fief": + return FiefAuthProvider( + base_url=settings.fief_url, + client_id=settings.fief_client_id, + client_secret=settings.fief_client_secret, + ) + elif provider_type == "none": + # No authentication - for development/internal use only + return NoAuthProvider() + else: + raise ValueError( + f"Unknown authentication provider: {provider_type}. " + f"Supported providers: 'fief', 'none'" + ) + + +def get_auth_provider() -> AuthProvider: + """ + Get the configured authentication provider instance. + + This is a convenience function that creates a provider using the + settings from the environment. + + Returns: + An instance of AuthProvider + """ + return create_auth_provider() diff --git a/carbonserver/carbonserver/api/services/auth_providers/fief_auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/fief_auth_provider.py new file mode 100644 index 000000000..527f65d54 --- /dev/null +++ b/carbonserver/carbonserver/api/services/auth_providers/fief_auth_provider.py @@ -0,0 +1,127 @@ +""" +Fief Authentication Provider Implementation + +This module provides a concrete implementation of AuthProvider using Fief. +""" + +from typing import Any, Dict, List, Optional, Tuple + +from fief_client import FiefAsync + +from carbonserver.api.services.auth_providers.auth_provider import AuthProvider + + +class FiefAuthProvider(AuthProvider): + """ + Fief-based authentication provider implementation. + + This class wraps the Fief client to provide authentication services + through the AuthProvider interface. + """ + + def __init__(self, base_url: str, client_id: str, client_secret: str): + """ + Initialize the Fief authentication provider. + + Args: + base_url: The Fief server base URL + client_id: The OAuth2 client ID + client_secret: The OAuth2 client secret + """ + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + self._client = FiefAsync(base_url, client_id, client_secret) + + async def get_auth_url( + self, redirect_uri: str, scope: List[str], state: Optional[str] = None + ) -> str: + """ + Generate the authorization URL for the OAuth2 flow using Fief. + + Args: + redirect_uri: The URI to redirect to after authentication + scope: List of OAuth2 scopes to request + state: Optional state parameter for CSRF protection + + Returns: + The authorization URL to redirect the user to + """ + return await self._client.auth_url(redirect_uri, scope=scope, state=state) + + async def handle_auth_callback( + self, code: str, redirect_uri: str + ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + """ + Handle the OAuth2 callback and exchange the code for tokens using Fief. + + Args: + code: The authorization code from the OAuth2 provider + redirect_uri: The redirect URI used in the initial auth request + + Returns: + A tuple of (tokens, user_info) where: + - tokens: Dict containing access_token, refresh_token, expires_in, etc. + - user_info: Optional dict containing user information + """ + tokens, user_info = await self._client.auth_callback(code, redirect_uri) + # Convert Fief objects to dicts + tokens_dict = dict(tokens) + user_info_dict = dict(user_info) if user_info else None + return (tokens_dict, user_info_dict) + + async def validate_access_token(self, token: str) -> bool: + """ + Validate an access token with Fief. + + Args: + token: The access token to validate + + Returns: + True if the token is valid + + Raises: + Exception if validation fails + """ + await self._client.validate_access_token(token) + return True + + async def get_user_info(self, access_token: str) -> Dict[str, Any]: + """ + Get user information from Fief. + + Args: + access_token: The access token for the user + + Returns: + Dict containing user information (sub, email, name, etc.) + """ + user_info = await self._client.userinfo(access_token) + return dict(user_info) + + def get_token_endpoint(self) -> str: + """ + Get the token endpoint URL for Fief. + + Returns: + The token endpoint URL + """ + return f"{self.base_url}/api/token" + + def get_authorize_endpoint(self) -> str: + """ + Get the authorization endpoint URL for Fief. + + Returns: + The authorization endpoint URL + """ + return f"{self.base_url}/authorize" + + def get_client_credentials(self) -> Tuple[str, str]: + """ + Get the client ID and client secret. + + Returns: + A tuple of (client_id, client_secret) + """ + return (self.client_id, self.client_secret) diff --git a/carbonserver/carbonserver/api/services/auth_providers/no_auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/no_auth_provider.py new file mode 100644 index 000000000..33f07aad1 --- /dev/null +++ b/carbonserver/carbonserver/api/services/auth_providers/no_auth_provider.py @@ -0,0 +1,128 @@ +""" +No Authentication Provider + +This provider allows running CodeCarbon without authentication requirements. +Useful for local development, internal networks, or testing environments. + +WARNING: Do not use in production environments exposed to the internet! +""" + +from typing import Any, Dict, List, Optional, Tuple + +from carbonserver.api.services.auth_providers.auth_provider import AuthProvider + + +class NoAuthProvider(AuthProvider): + """ + No-authentication provider for development and internal use. + + This provider bypasses authentication checks and always succeeds. + It's useful for: + - Local development without setting up OAuth + - Internal networks with other security measures + - Testing and CI/CD pipelines + + WARNING: Never use this in production environments exposed to the internet! + """ + + def __init__(self): + """Initialize the no-auth provider.""" + self.base_url = "http://localhost" + self.client_id = "no-auth" + self.client_secret = "no-auth" + + async def get_auth_url( + self, redirect_uri: str, scope: List[str], state: Optional[str] = None + ) -> str: + """ + Return a dummy auth URL (not used in no-auth mode). + + Args: + redirect_uri: The URI to redirect to after authentication + scope: List of OAuth2 scopes to request + state: Optional state parameter for CSRF protection + + Returns: + The redirect URI (authentication is bypassed) + """ + return redirect_uri + + async def handle_auth_callback( + self, code: str, redirect_uri: str + ) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]: + """ + Return dummy tokens and user info (authentication bypassed). + + Args: + code: The authorization code from the OAuth2 provider + redirect_uri: The redirect URI used in the initial auth request + + Returns: + A tuple of (tokens, user_info) with default values + """ + tokens = { + "access_token": "no-auth-token", + "token_type": "Bearer", + "expires_in": 3600, + } + user_info = { + "sub": "no-auth-user", + "email": "noauth@localhost", + "name": "No Auth User", + } + return (tokens, user_info) + + async def validate_access_token(self, token: str) -> bool: + """ + Always return True (no validation in no-auth mode). + + Args: + token: The access token to validate + + Returns: + Always True + """ + return True + + async def get_user_info(self, access_token: str) -> Dict[str, Any]: + """ + Return default user info (no actual authentication). + + Args: + access_token: The access token for the user + + Returns: + Dict containing default user information + """ + return { + "sub": "no-auth-user", + "email": "noauth@localhost", + "name": "No Auth User", + } + + def get_token_endpoint(self) -> str: + """ + Get a dummy token endpoint URL. + + Returns: + The token endpoint URL (not used) + """ + return f"{self.base_url}/token" + + def get_authorize_endpoint(self) -> str: + """ + Get a dummy authorization endpoint URL. + + Returns: + The authorization endpoint URL (not used) + """ + return f"{self.base_url}/authorize" + + def get_client_credentials(self) -> Tuple[str, str]: + """ + Get dummy client credentials. + + Returns: + A tuple of (client_id, client_secret) + """ + return (self.client_id, self.client_secret) diff --git a/carbonserver/carbonserver/api/services/auth_service.py b/carbonserver/carbonserver/api/services/auth_service.py index a1d85df22..a11cec323 100644 --- a/carbonserver/carbonserver/api/services/auth_service.py +++ b/carbonserver/carbonserver/api/services/auth_service.py @@ -5,20 +5,13 @@ from dependency_injector.wiring import Provide from fastapi import Depends, HTTPException from fastapi.security import APIKeyCookie, HTTPBearer, OAuth2AuthorizationCodeBearer -from fief_client import FiefAsync, FiefUserInfo -from fief_client.integrations.fastapi import FiefAuth -from starlette import status -from starlette.requests import Request -from starlette.responses import Response +from carbonserver.api.services.auth_providers.auth_provider import AuthProvider from carbonserver.api.services.user_service import UserService from carbonserver.config import settings from carbonserver.container import ServerContainer OAUTH_SCOPES = ["openid", "email", "profile"] -fief = FiefAsync( - settings.fief_url, settings.fief_client_id, settings.fief_client_secret -) @dataclass @@ -28,30 +21,27 @@ class FullUser: SESSION_COOKIE_NAME = "user_session" -scheme = OAuth2AuthorizationCodeBearer( - settings.fief_url + "/authorize", - settings.fief_url + "/api/token", - scopes={x: x for x in OAUTH_SCOPES}, - auto_error=False, -) -web_scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False) -fief_auth_cookie = FiefAuth(fief, web_scheme) -class UserOrRedirectAuth(FiefAuth): - client: FiefAsync +def get_oauth_scheme(auth_provider: AuthProvider) -> OAuth2AuthorizationCodeBearer: + """ + Get the OAuth2 scheme for the configured auth provider. - async def get_unauthorized_response(self, request: Request, response: Response): - redirect_uri = request.url_for("auth_callback") - auth_url = await self.client.auth_url(redirect_uri, scope=OAUTH_SCOPES) + Args: + auth_provider: The authentication provider instance - raise HTTPException( - status_code=status.HTTP_307_TEMPORARY_REDIRECT, - headers={"Location": str(auth_url)}, - ) + Returns: + OAuth2AuthorizationCodeBearer configured for the provider + """ + return OAuth2AuthorizationCodeBearer( + auth_provider.get_authorize_endpoint(), + auth_provider.get_token_endpoint(), + scopes={x: x for x in OAUTH_SCOPES}, + auto_error=False, + ) -web_auth_with_redirect = UserOrRedirectAuth(fief, web_scheme) +web_scheme = APIKeyCookie(name=SESSION_COOKIE_NAME, auto_error=False) class UserWithAuthDependency: @@ -68,14 +58,12 @@ def __init__(self, error_if_not_found=False): async def __call__( self, - auth_user_cookie: Optional[FiefUserInfo] = Depends( - fief_auth_cookie.current_user(optional=True) - ), cookie_token: Optional[str] = Depends(web_scheme), bearer_token: Optional[str] = Depends(HTTPBearer(auto_error=False)), user_service: Optional[UserService] = Depends( Provide[ServerContainer.user_service] ), + auth_provider: AuthProvider = Depends(Provide[ServerContainer.auth_provider]), ): self.user_service = user_service if cookie_token is not None: @@ -87,10 +75,10 @@ async def __call__( elif bearer_token is not None: if settings.environment != "develop": try: - await fief.validate_access_token(bearer_token.credentials) + await auth_provider.validate_access_token(bearer_token.credentials) except Exception: raise HTTPException(status_code=401, detail="Invalid token") - # cli user using fief token + # cli user using auth provider token self.auth_user = jwt.decode( bearer_token.credentials, options={"verify_signature": False}, diff --git a/carbonserver/carbonserver/config.py b/carbonserver/carbonserver/config.py index f62c674db..c72a4a180 100644 --- a/carbonserver/carbonserver/config.py +++ b/carbonserver/carbonserver/config.py @@ -6,9 +6,14 @@ class Settings(BaseSettings): "postgresql://codecarbon-user:supersecret@localhost:5432/codecarbon_db", env="DATABASE_URL", ) + # Authentication provider settings + auth_provider: str = Field("fief", env="AUTH_PROVIDER") # Options: 'fief', 'none' + + # Fief-specific settings (kept for backward compatibility) fief_client_id: str = Field("", env="FIEF_CLIENT_ID") fief_client_secret: str = Field("", env="FIEF_CLIENT_SECRET") fief_url: str = Field("https://auth.codecarbon.io/codecarbon-dev", env="FIEF_URL") + frontend_url: str = Field("", env="FRONTEND_URL") environment: str = Field("production") jwt_key: str = Field("", env="JWT_KEY") diff --git a/carbonserver/carbonserver/container.py b/carbonserver/carbonserver/container.py index 7e5176b2b..22406a3d9 100644 --- a/carbonserver/carbonserver/container.py +++ b/carbonserver/carbonserver/container.py @@ -11,6 +11,9 @@ repository_users, ) from carbonserver.api.services.auth_context import AuthContext +from carbonserver.api.services.auth_providers.auth_provider_factory import ( + get_auth_provider, +) from carbonserver.api.services.emissions_service import EmissionService from carbonserver.api.services.experiments_service import ExperimentService from carbonserver.api.services.organization_service import OrganizationService @@ -39,6 +42,11 @@ class ServerContainer(containers.DeclarativeContainer): Database, db_url=db_url, ) + + # Authentication provider (configurable via AUTH_PROVIDER env var) + # Options: 'fief' (default) or 'none' (dev/testing only) + auth_provider = providers.Callable(get_auth_provider) + emission_repository = providers.Factory( repository_emissions.SqlAlchemyRepository, session_factory=db.provided.session, diff --git a/carbonserver/main.py b/carbonserver/main.py index f8b9d23d2..3a3611b9f 100644 --- a/carbonserver/main.py +++ b/carbonserver/main.py @@ -20,6 +20,7 @@ runs, users, ) +from carbonserver.api.services import auth_service from carbonserver.config import settings from carbonserver.container import ServerContainer from carbonserver.database.database import engine @@ -69,6 +70,7 @@ def init_container(): organizations, users, authenticate, + auth_service, ] ) return container diff --git a/carbonserver/tests/api/service/test_auth_provider.py b/carbonserver/tests/api/service/test_auth_provider.py new file mode 100644 index 000000000..c54c02dbe --- /dev/null +++ b/carbonserver/tests/api/service/test_auth_provider.py @@ -0,0 +1,89 @@ +""" +Unit tests for authentication provider interface and implementations. +""" + +import pytest + +from carbonserver.api.services.auth_providers.fief_auth_provider import FiefAuthProvider + + +class TestAuthProviderInterface: + """Test that providers implement the interface correctly.""" + + def test_no_auth_provider_interface(self): + """Test that NoAuthProvider implements all required methods.""" + from carbonserver.api.services.auth_providers.no_auth_provider import ( + NoAuthProvider, + ) + + provider = NoAuthProvider() + + # Check all required methods exist + assert hasattr(provider, "get_auth_url") + assert hasattr(provider, "handle_auth_callback") + assert hasattr(provider, "validate_access_token") + assert hasattr(provider, "get_user_info") + assert hasattr(provider, "get_token_endpoint") + assert hasattr(provider, "get_authorize_endpoint") + assert hasattr(provider, "get_client_credentials") + + # Test synchronous endpoint methods + assert provider.get_token_endpoint() == "http://localhost/token" + assert provider.get_authorize_endpoint() == "http://localhost/authorize" + assert provider.get_client_credentials() == ("no-auth", "no-auth") + + def test_fief_provider_interface(self): + """Test that FiefAuthProvider implements all required methods.""" + provider = FiefAuthProvider( + base_url="https://auth.example.com", + client_id="test_client", + client_secret="test_secret", + ) + + # Check all required methods exist + assert hasattr(provider, "get_auth_url") + assert hasattr(provider, "handle_auth_callback") + assert hasattr(provider, "validate_access_token") + assert hasattr(provider, "get_user_info") + assert hasattr(provider, "get_token_endpoint") + assert hasattr(provider, "get_authorize_endpoint") + assert hasattr(provider, "get_client_credentials") + + # Test endpoint methods + assert provider.get_token_endpoint() == "https://auth.example.com/api/token" + assert provider.get_authorize_endpoint() == "https://auth.example.com/authorize" + assert provider.get_client_credentials() == ("test_client", "test_secret") + + +class TestAuthProviderFactory: + """Test the auth provider factory.""" + + def test_factory_creates_fief_provider(self): + """Test that factory creates Fief provider when configured.""" + from carbonserver.api.services.auth_providers.auth_provider_factory import ( + create_auth_provider, + ) + + provider = create_auth_provider("fief") + assert isinstance(provider, FiefAuthProvider) + + def test_factory_creates_no_auth_provider(self): + """Test that factory creates no-auth provider when configured.""" + from carbonserver.api.services.auth_providers.auth_provider_factory import ( + create_auth_provider, + ) + from carbonserver.api.services.auth_providers.no_auth_provider import ( + NoAuthProvider, + ) + + provider = create_auth_provider("none") + assert isinstance(provider, NoAuthProvider) + + def test_factory_raises_on_unknown_provider(self): + """Test that factory raises error for unknown provider.""" + from carbonserver.api.services.auth_providers.auth_provider_factory import ( + create_auth_provider, + ) + + with pytest.raises(ValueError, match="Unknown authentication provider"): + create_auth_provider("unknown_provider")