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. Authentication concerns are intentionally decoupled from adapter logic.
Adapters depend only on this interface and must not be aware of how 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 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 This interface enforces a strict contract between authentication
OAuth or credential implementations. providers and mail adapters by requiring providers to explicitly
declare the type of credentials they return.
Authentication providers encapsulate all logic required to acquire Authentication providers encapsulate all logic required to:
valid credentials for a mail provider. - 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: Mail adapters must treat returned credentials as opaque and
- OAuth flows provider-specific, relying only on the declared credential type
- Service account credentials expected by the adapter.
- Token refresh logic
- Secure credential storage
Adapters must treat the returned credentials as opaque and provider-specific.
""" """
@abstractmethod @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 This method is synchronous by design and represents the sole
return valid credentials or raise MailIntakeAuthError. 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: Returns:
Provider-specific credentials object suitable for use by Credentials of type ``T`` suitable for immediate use by the
the corresponding mail adapter. corresponding mail adapter.
Raises: Raises:
Exception: Authentication-specific errors defined by the Exception:
implementation. An authentication-specific exception indicating that
credentials could not be obtained or validated.
""" """
raise NotImplementedError raise NotImplementedError

View File

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

View File

@@ -0,0 +1,8 @@
"""
from mail_intake.credentials.store import CredentialStore
from google.oauth2.credentials import Credentials
GoogleCredentialStore = CredentialStore[Credentials]
"""

View File

@@ -0,0 +1,96 @@
"""
Local filesystembased 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)

View File

@@ -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)

View File

@@ -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.
"""