diff --git a/mail_intake/auth/base.py b/mail_intake/auth/base.py index 24cfcdb..f48a500 100644 --- a/mail_intake/auth/base.py +++ b/mail_intake/auth/base.py @@ -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 diff --git a/mail_intake/auth/google.py b/mail_intake/auth/google.py index a6dcc3e..ea05aee 100644 --- a/mail_intake/auth/google.py +++ b/mail_intake/auth/google.py @@ -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 diff --git a/mail_intake/credentials/__init__.py b/mail_intake/credentials/__init__.py new file mode 100644 index 0000000..d0d65c1 --- /dev/null +++ b/mail_intake/credentials/__init__.py @@ -0,0 +1,8 @@ +""" +from mail_intake.credentials.store import CredentialStore + +from google.oauth2.credentials import Credentials + +GoogleCredentialStore = CredentialStore[Credentials] + +""" \ No newline at end of file diff --git a/mail_intake/credentials/pickle.py b/mail_intake/credentials/pickle.py new file mode 100644 index 0000000..fb8ed11 --- /dev/null +++ b/mail_intake/credentials/pickle.py @@ -0,0 +1,96 @@ +""" +Local filesystem–based credential persistence for Mail Intake. + +This module provides a file-backed implementation of the +``CredentialStore`` abstraction using Python's ``pickle`` module. + +The pickle-based credential store is intended for local development, +single-node deployments, and controlled environments where credentials +do not need to be shared across processes or machines. + +Due to the security and portability risks associated with pickle-based +serialization, this implementation is not suitable for distributed or +untrusted environments. +""" + +import pickle +from typing import Optional, TypeVar + +from mail_intake.credentials.store import CredentialStore + +T = TypeVar("T") + + +class PickleCredentialStore(CredentialStore[T]): + """ + Filesystem-backed credential store using pickle serialization. + + This store persists credentials as a pickled object on the local + filesystem. It is a simple implementation intended primarily for + development, testing, and single-process execution contexts. + + This implementation: + - Stores credentials on the local filesystem + - Uses pickle for serialization and deserialization + - Does not provide encryption, locking, or concurrency guarantees + + Credential lifecycle management, validation, and refresh logic are + explicitly out of scope for this class. + """ + + def __init__(self, path: str): + """ + Initialize a pickle-backed credential store. + + Args: + path: + Filesystem path where credentials will be stored. + The file will be created or overwritten as needed. + """ + self.path = path + + def load(self) -> Optional[T]: + """ + Load credentials from the local filesystem. + + If the credential file does not exist or cannot be successfully + deserialized, this method returns ``None``. + + The store does not attempt to validate or interpret the returned + credentials. + + Returns: + An instance of type ``T`` if credentials are present and + successfully deserialized; otherwise ``None``. + """ + try: + with open(self.path, "rb") as fh: + return pickle.load(fh) + except Exception: + return None + + def save(self, credentials: T) -> None: + """ + Persist credentials to the local filesystem. + + Any previously stored credentials at the configured path are + overwritten. + + Args: + credentials: + The credential object to persist. + """ + with open(self.path, "wb") as fh: + pickle.dump(credentials, fh) + + def clear(self) -> None: + """ + Remove persisted credentials from the local filesystem. + + This method deletes the credential file if it exists and should + be treated as an idempotent operation. + """ + import os + + if os.path.exists(self.path): + os.remove(self.path) diff --git a/mail_intake/credentials/redis.py b/mail_intake/credentials/redis.py new file mode 100644 index 0000000..2b112d2 --- /dev/null +++ b/mail_intake/credentials/redis.py @@ -0,0 +1,142 @@ +""" +Redis-backed credential persistence for Mail Intake. + +This module provides a Redis-based implementation of the +``CredentialStore`` abstraction, enabling credential persistence +across distributed and horizontally scaled deployments. + +The Redis credential store is designed for environments where +authentication credentials must be shared safely across multiple +processes, containers, or nodes, such as container orchestration +platforms and microservice architectures. + +Key characteristics: +- Distributed-safe, shared storage using Redis +- Explicit, caller-defined serialization and deserialization +- No reliance on unsafe mechanisms such as pickle +- Optional time-to-live (TTL) support for automatic credential expiry + +This module is responsible solely for persistence concerns. +Credential validation, refresh, rotation, and acquisition remain the +responsibility of authentication provider implementations. +""" + + +from typing import Optional, TypeVar, Callable + +from mail_intake.credentials.store import CredentialStore + +T = TypeVar("T") + + +class RedisCredentialStore(CredentialStore[T]): + """ + Redis-backed implementation of ``CredentialStore``. + + This store persists credentials in Redis and is suitable for + distributed and horizontally scaled deployments where credentials + must be shared across multiple processes or nodes. + + The store is intentionally generic and delegates all serialization + concerns to caller-provided functions. This avoids unsafe mechanisms + such as pickle and allows credential formats to be explicitly + controlled and audited. + + This class is responsible only for persistence and retrieval. + It does not interpret, validate, refresh, or otherwise manage + the lifecycle of the credentials being stored. + """ + + def __init__( + self, + redis_client, + key: str, + serialize: Callable[[T], bytes], + deserialize: Callable[[bytes], T], + ttl_seconds: Optional[int] = None, + ): + """ + Initialize a Redis-backed credential store. + + Args: + redis_client: + An initialized Redis client instance (for example, + ``redis.Redis`` or a compatible interface) used to + communicate with the Redis server. + + key: + The Redis key under which credentials are stored. + Callers are responsible for applying appropriate + namespacing to avoid collisions. + + serialize: + A callable that converts a credential object of type + ``T`` into a ``bytes`` representation suitable for + storage in Redis. + + deserialize: + A callable that converts a ``bytes`` payload retrieved + from Redis back into a credential object of type ``T``. + + ttl_seconds: + Optional time-to-live (TTL) for the stored credentials, + expressed in seconds. When provided, Redis will + automatically expire the stored credentials after the + specified duration. If ``None``, credentials are stored + without an expiration. + """ + self.redis = redis_client + self.key = key + self.serialize = serialize + self.deserialize = deserialize + self.ttl_seconds = ttl_seconds + + def load(self) -> Optional[T]: + """ + Load credentials from Redis. + + If no value exists for the configured key, or if the stored + payload cannot be successfully deserialized, this method + returns ``None``. + + The store does not attempt to validate the returned credentials + or determine whether they are expired or otherwise usable. + + Returns: + An instance of type ``T`` if credentials are present and + successfully deserialized; otherwise ``None``. + """ + raw = self.redis.get(self.key) + if not raw: + return None + try: + return self.deserialize(raw) + except Exception: + return None + + def save(self, credentials: T) -> None: + """ + Persist credentials to Redis. + + Any previously stored credentials under the same key are + overwritten. If a TTL is configured, the credentials will + expire automatically after the specified duration. + + Args: + credentials: + The credential object to persist. + """ + payload = self.serialize(credentials) + if self.ttl_seconds: + self.redis.setex(self.key, self.ttl_seconds, payload) + else: + self.redis.set(self.key, payload) + + def clear(self) -> None: + """ + Remove stored credentials from Redis. + + This operation deletes the configured Redis key if it exists. + Implementations should treat this method as idempotent. + """ + self.redis.delete(self.key) diff --git a/mail_intake/credentials/store.py b/mail_intake/credentials/store.py new file mode 100644 index 0000000..92e2af7 --- /dev/null +++ b/mail_intake/credentials/store.py @@ -0,0 +1,96 @@ +""" +Credential persistence abstractions for Mail Intake. + +This module defines the generic persistence contract used to store and +retrieve authentication credentials across Mail Intake components. + +The ``CredentialStore`` abstraction establishes a strict separation +between credential *lifecycle management* and credential *storage*. +Authentication providers are responsible for acquiring, validating, +refreshing, and revoking credentials, while concrete store +implementations are responsible solely for persistence concerns. + +By remaining agnostic to credential structure, serialization format, +and storage backend, this module enables multiple persistence +strategies—such as local files, in-memory caches, distributed stores, +or secrets managers—without coupling authentication logic to any +specific storage mechanism. +""" + + +from abc import ABC, abstractmethod +from typing import Generic, Optional, TypeVar + +T = TypeVar("T") + + +class CredentialStore(ABC, Generic[T]): + """ + Abstract base class defining a generic persistence interface for + authentication credentials. + + This interface separates *credential lifecycle management* from + *credential storage mechanics*. Implementations are responsible + only for persistence concerns, while authentication providers + retain full control over credential creation, validation, refresh, + and revocation logic. + + The store is intentionally agnostic to: + - The concrete credential type being stored + - The serialization format used to persist credentials + - The underlying storage backend or durability guarantees + + Type Parameters: + T: + The concrete credential type managed by the store. This may + represent OAuth credentials, API tokens, session objects, + or any other authentication material. + """ + + @abstractmethod + def load(self) -> Optional[T]: + """ + Load previously persisted credentials. + + Implementations should return ``None`` when no credentials are + present or when stored credentials cannot be successfully + decoded or deserialized. + + The store must not attempt to validate, refresh, or otherwise + interpret the returned credentials. + + Returns: + An instance of type ``T`` if credentials are available and + loadable; otherwise ``None``. + """ + + @abstractmethod + def save(self, credentials: T) -> None: + """ + Persist credentials to the underlying storage backend. + + This method is invoked when credentials are newly obtained or + have been refreshed and are known to be valid at the time of + persistence. + + Implementations are responsible for: + - Ensuring durability appropriate to the deployment context + - Applying encryption or access controls where required + - Overwriting any previously stored credentials + + Args: + credentials: + The credential object to persist. + """ + + @abstractmethod + def clear(self) -> None: + """ + Remove any persisted credentials from the store. + + This method is called when credentials are known to be invalid, + revoked, corrupted, or otherwise unusable, and must ensure that + no stale authentication material remains accessible. + + Implementations should treat this operation as idempotent. + """