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

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