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.
Summary
AuthServiceis 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
AuthServicehas direct dependencies on ORCiD:_orcid_client: OrcidOAuthClientfieldget_login_url()delegates directly to ORCiD clientauthenticate()contains ORCiD-specific flow:IdentityProvider.ORCIDhardcodedProposed Design
1. Create
IdentityProviderClientport2. Create
AuthenticatedIdentityvalue object3. Create provider registry/factory
4. Refactor
AuthService5. Update routes
Routes would need to accept provider as a parameter:
GET /auth/login/{provider}- initiate OAuth for specific providerGET /auth/callback/{provider}- handle callback for specific providerImplementation Notes
OrcidOAuthClientbecomes an adapter implementingIdentityProviderClientRelated
LinkedIdentityrefactoring that introduced provider-agnostic identity storage