Skip to content

refactor: decouple AuthService from ORCiD-specific identity provider #47

@rorybyrne

Description

@rorybyrne

Summary

AuthService is currently tightly coupled to ORCiD as the identity provider. To support multiple identity providers in the future (GitHub, institutional SSO, etc.), we should abstract the identity provider interaction behind a port/adapter pattern.

Current State

AuthService has direct dependencies on ORCiD:

  • _orcid_client: OrcidOAuthClient field
  • get_login_url() delegates directly to ORCiD client
  • authenticate() contains ORCiD-specific flow:
    • Exchanges code via ORCiD client
    • Gets userinfo from ORCiD endpoint
    • Uses IdentityProvider.ORCID hardcoded

Proposed Design

1. Create IdentityProviderClient port

class IdentityProviderClient(ABC):
    """Abstract interface for OAuth identity providers."""
    
    @property
    @abstractmethod
    def provider(self) -> IdentityProvider:
        """Return the provider type this client handles."""
        ...
    
    @abstractmethod
    def get_authorization_url(self, state: str) -> str:
        """Get the OAuth authorization URL."""
        ...
    
    @abstractmethod
    async def authenticate(self, code: str) -> AuthenticatedIdentity:
        """Exchange code and return authenticated identity info."""
        ...

2. Create AuthenticatedIdentity value object

class AuthenticatedIdentity(BaseModel, frozen=True):
    """Result of successful identity provider authentication."""
    provider: IdentityProvider
    provider_id: str
    display_name: str | None = None
    email: str | None = None
    # Provider-specific metadata if needed
    raw_claims: dict[str, Any] = Field(default_factory=dict)

3. Create provider registry/factory

class IdentityProviderRegistry:
    """Registry of available identity provider clients."""
    
    def get_client(self, provider: IdentityProvider) -> IdentityProviderClient:
        """Get the client for a specific provider."""
        ...
    
    def list_providers(self) -> list[IdentityProvider]:
        """List available providers."""
        ...

4. Refactor AuthService

class AuthService(Service):
    _provider_registry: IdentityProviderRegistry
    _user_repo: UserRepository
    _supabase_client: SupabaseAuthClient
    _outbox: Outbox
    
    def get_login_url(self, provider: IdentityProvider, state: str) -> str:
        client = self._provider_registry.get_client(provider)
        return client.get_authorization_url(state)
    
    async def authenticate(
        self, provider: IdentityProvider, code: str
    ) -> tuple[User, SupabaseSession]:
        client = self._provider_registry.get_client(provider)
        identity_info = await client.authenticate(code)
        # ... generic user creation/lookup logic

5. Update routes

Routes would need to accept provider as a parameter:

  • GET /auth/login/{provider} - initiate OAuth for specific provider
  • GET /auth/callback/{provider} - handle callback for specific provider

Implementation Notes

  • OrcidOAuthClient becomes an adapter implementing IdentityProviderClient
  • Future providers (GitHub, OIDC, SAML) implement the same interface
  • Provider-specific config moves to each adapter
  • Keep backward compatibility during transition if needed

Related

  • Builds on the LinkedIdentity refactoring that introduced provider-agnostic identity storage
  • Enables future work on GitHub authentication, institutional SSO, etc.

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactorInternal restructuring, no behavior change

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions