refactor(auth): type auth providers and decouple Google auth from disk storage

- Make MailIntakeAuthProvider generic over credential type to enforce
  typed auth contracts between providers and adapters
- Refactor Google OAuth provider to use CredentialStore abstraction
  instead of filesystem-based pickle persistence
- Remove node-local state assumptions from Google auth implementation
- Clarify documentation to distinguish credential lifecycle from
  credential persistence responsibilities

This change enables distributed-safe authentication providers and
allows multiple credential persistence strategies without modifying
auth logic.
This commit is contained in:
2026-01-10 16:40:51 +05:30
parent 24b3b04cfe
commit 4e63c36199
6 changed files with 399 additions and 47 deletions

View File

@@ -6,45 +6,54 @@ adapters to obtain provider-specific credentials.
Authentication concerns are intentionally decoupled from adapter logic.
Adapters depend only on this interface and must not be aware of how
credentials are acquired, refreshed, or stored.
credentials are acquired, refreshed, or persisted.
"""
from abc import ABC, abstractmethod
from typing import Generic, TypeVar
T = TypeVar("T")
class MailIntakeAuthProvider(ABC):
class MailIntakeAuthProvider(ABC, Generic[T]):
"""
Abstract authentication provider.
Abstract base class for authentication providers.
Mail adapters depend on this interface, not on concrete
OAuth or credential implementations.
This interface enforces a strict contract between authentication
providers and mail adapters by requiring providers to explicitly
declare the type of credentials they return.
Authentication providers encapsulate all logic required to acquire
valid credentials for a mail provider.
Authentication providers encapsulate all logic required to:
- Acquire credentials from an external provider
- Refresh or revalidate credentials as needed
- Handle authentication-specific failure modes
- Coordinate with credential persistence layers where applicable
Implementations may involve:
- OAuth flows
- Service account credentials
- Token refresh logic
- Secure credential storage
Adapters must treat the returned credentials as opaque and provider-specific.
Mail adapters must treat returned credentials as opaque and
provider-specific, relying only on the declared credential type
expected by the adapter.
"""
@abstractmethod
def get_credentials(self):
def get_credentials(self) -> T:
"""
Return provider-specific credentials object.
Retrieve valid, provider-specific credentials.
This method is synchronous by design and must either
return valid credentials or raise MailIntakeAuthError.
This method is synchronous by design and represents the sole
entry point through which adapters obtain authentication
material.
Implementations must either return credentials of the declared
type ``T`` that are valid at the time of return or raise an
authentication-specific exception.
Returns:
Provider-specific credentials object suitable for use by
the corresponding mail adapter.
Credentials of type ``T`` suitable for immediate use by the
corresponding mail adapter.
Raises:
Exception: Authentication-specific errors defined by the
implementation.
Exception:
An authentication-specific exception indicating that
credentials could not be obtained or validated.
"""
raise NotImplementedError

View File

@@ -8,19 +8,21 @@ It encapsulates all Google-specific authentication concerns, including:
- Credential loading and persistence
- Token refresh handling
- Interactive OAuth flow initiation
- Coordination with a credential persistence layer
No Google authentication details should leak outside this module.
"""
import os
import pickle
from typing import Sequence
import google.auth.exceptions
from google.auth.transport.requests import Request
from google_auth_oauthlib.flow import InstalledAppFlow
from google.oauth2.credentials import Credentials
from mail_intake.auth.base import MailIntakeAuthProvider
from mail_intake.credentials.store import CredentialStore
from mail_intake.exceptions import MailIntakeAuthError
@@ -32,10 +34,10 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
Google's OAuth 2.0 flow and credential management libraries.
Responsibilities:
- Load cached credentials from disk when available
- Load cached credentials from a credential store when available
- Refresh expired credentials when possible
- Initiate an interactive OAuth flow only when required
- Persist refreshed or newly obtained credentials
- Persist refreshed or newly obtained credentials via the store
This class is synchronous by design and maintains a minimal internal state.
"""
@@ -43,48 +45,47 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
def __init__(
self,
credentials_path: str,
token_path: str,
store: CredentialStore[Credentials],
scopes: Sequence[str],
):
"""
Initialize the Google authentication provider.
Args:
credentials_path: Path to the Google OAuth client secrets file.
token_path: Path where OAuth tokens will be cached.
scopes: OAuth scopes required for access.
credentials_path:
Path to the Google OAuth client secrets file used to
initiate the OAuth 2.0 flow.
store:
Credential store responsible for persisting and
retrieving Google OAuth credentials.
scopes:
OAuth scopes required for Gmail access.
"""
self.credentials_path = credentials_path
self.token_path = token_path
self.store = store
self.scopes = list(scopes)
def get_credentials(self):
def get_credentials(self) -> Credentials:
"""
Retrieve valid Google OAuth credentials.
This method attempts to:
1. Load cached credentials from disk
1. Load cached credentials from the configured credential store
2. Refresh expired credentials when possible
3. Perform an interactive OAuth login as a fallback
4. Persist valid credentials for future use
Returns:
Google OAuth credentials object suitable for use with
Google API clients.
A ``google.oauth2.credentials.Credentials`` instance suitable
for use with Google API clients.
Raises:
MailIntakeAuthError: If credentials cannot be loaded, refreshed,
or obtained via interactive authentication.
"""
creds = None
# Attempt to load cached credentials
if os.path.exists(self.token_path):
try:
with open(self.token_path, "rb") as fh:
creds = pickle.load(fh)
except Exception:
creds = None
creds = self.store.load()
# Validate / refresh credentials
if not creds or not creds.valid:
@@ -92,6 +93,7 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
try:
creds.refresh(Request())
except google.auth.exceptions.RefreshError:
self.store.clear()
creds = None
# Interactive login if refresh failed or creds missing
@@ -112,13 +114,12 @@ class MailIntakeGoogleAuth(MailIntakeAuthProvider):
"Failed to complete Google OAuth flow"
) from exc
# Persist refreshed / new credentials
# Persist refreshed or newly obtained credentials
try:
with open(self.token_path, "wb") as fh:
pickle.dump(creds, fh)
self.store.save(creds)
except Exception as exc:
raise MailIntakeAuthError(
f"Failed to write token file: {self.token_path}"
"Failed to persist Google OAuth credentials"
) from exc
return creds