docs(code): add concise docstrings
This commit is contained in:
@@ -28,6 +28,8 @@ from gapido_auth.domain.ports import (
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthConfig:
|
||||
"""Runtime policy values used by auth use cases."""
|
||||
|
||||
otp_secret: str
|
||||
otp_ttl_seconds: int
|
||||
otp_max_attempts: int
|
||||
@@ -38,6 +40,8 @@ class AuthConfig:
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Application service coordinating OTP login, token rotation, and RBAC."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
users: UserRepository,
|
||||
@@ -47,6 +51,7 @@ class AuthService:
|
||||
token_codec: JwtTokenCodec,
|
||||
config: AuthConfig,
|
||||
) -> None:
|
||||
"""Wire repository, queue, OTP, and token ports for use-case execution."""
|
||||
self._users = users
|
||||
self._refresh_sessions = refresh_sessions
|
||||
self._otp_store = otp_store
|
||||
@@ -55,6 +60,8 @@ class AuthService:
|
||||
self._config = config
|
||||
|
||||
async def request_otp(self, mobile: str, purpose: str, client_key: str) -> None:
|
||||
"""Validate a request, store an OTP hash, and queue SMS delivery."""
|
||||
|
||||
_validate_mobile(mobile)
|
||||
_validate_purpose(purpose)
|
||||
mobile_key = f"otp-request:mobile:{mobile}:{purpose}"
|
||||
@@ -73,6 +80,7 @@ class AuthService:
|
||||
raise RateLimitExceeded("too many OTP requests")
|
||||
|
||||
code = generate_otp_code()
|
||||
# Store only a bound HMAC hash; the plaintext code leaves through the SMS queue only.
|
||||
otp_hash = hash_otp(self._config.otp_secret, mobile, purpose, code)
|
||||
await self._otp_store.store_otp(
|
||||
mobile=mobile,
|
||||
@@ -86,6 +94,8 @@ class AuthService:
|
||||
)
|
||||
|
||||
async def verify_otp(self, mobile: str, code: str, purpose: str) -> TokenPair:
|
||||
"""Verify the submitted OTP and issue a token pair for the mobile identity."""
|
||||
|
||||
_validate_mobile(mobile)
|
||||
_validate_purpose(purpose)
|
||||
if not code.isdigit() or len(code) != 6:
|
||||
@@ -100,6 +110,8 @@ class AuthService:
|
||||
return await self._issue_token_pair(user)
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> TokenPair:
|
||||
"""Rotate a valid refresh token and return a new access/refresh pair."""
|
||||
|
||||
token_hash = hash_refresh_token(refresh_token)
|
||||
now = utc_now()
|
||||
session = await self._refresh_sessions.get_active_by_hash(token_hash, now)
|
||||
@@ -110,6 +122,7 @@ class AuthService:
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("invalid refresh token")
|
||||
|
||||
# Rotation revokes the old token hash and persists a fresh session hash.
|
||||
new_refresh_token = generate_refresh_token()
|
||||
new_hash = hash_refresh_token(new_refresh_token)
|
||||
expires_at = now + timedelta(seconds=self._config.refresh_token_ttl_seconds)
|
||||
@@ -125,9 +138,13 @@ class AuthService:
|
||||
)
|
||||
|
||||
async def revoke_refresh_token(self, refresh_token: str) -> None:
|
||||
"""Revoke a refresh token session if it is still active."""
|
||||
|
||||
await self._refresh_sessions.revoke(hash_refresh_token(refresh_token), utc_now())
|
||||
|
||||
async def get_authenticated_user(self, access_token: str) -> User:
|
||||
"""Resolve an access token into an active user entity."""
|
||||
|
||||
claims = self._token_codec.decode_access_token(access_token)
|
||||
user = await self._users.get_by_id(claims.user_id)
|
||||
if user is None:
|
||||
@@ -137,12 +154,16 @@ class AuthService:
|
||||
return user
|
||||
|
||||
async def require_role(self, access_token: str, role: Role) -> User:
|
||||
"""Resolve a user and ensure the requested role is present."""
|
||||
|
||||
user = await self.get_authenticated_user(access_token)
|
||||
if user.role != role:
|
||||
raise PermissionDenied("insufficient permissions")
|
||||
return user
|
||||
|
||||
async def _issue_token_pair(self, user: User) -> TokenPair:
|
||||
"""Create a JWT access token and persisted refresh session for a user."""
|
||||
|
||||
if not user.is_active:
|
||||
raise InactiveUser("user is inactive")
|
||||
|
||||
@@ -160,11 +181,15 @@ class AuthService:
|
||||
|
||||
|
||||
def _validate_mobile(mobile: str) -> None:
|
||||
"""Validate the E.164-like mobile format accepted by the challenge service."""
|
||||
|
||||
normalized = mobile.removeprefix("+")
|
||||
if not normalized.isdigit() or len(normalized) < 10 or len(normalized) > 15:
|
||||
raise ValidationError("mobile must be an E.164-like phone number")
|
||||
|
||||
|
||||
def _validate_purpose(purpose: str) -> None:
|
||||
"""Validate the OTP purpose used to namespace OTP hashes and rate limits."""
|
||||
|
||||
if not purpose or not purpose.replace("-", "").replace("_", "").isalnum():
|
||||
raise ValidationError("purpose is invalid")
|
||||
|
||||
@@ -11,37 +11,54 @@ from gapido_auth.domain.errors import AuthenticationError
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return timezone-aware UTC time for token/session timestamps."""
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def generate_otp_code() -> str:
|
||||
"""Generate a cryptographically random six-digit OTP string."""
|
||||
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def hash_otp(secret: str, mobile: str, purpose: str, code: str) -> str:
|
||||
"""Bind an OTP to its mobile and purpose before storing only an HMAC hash."""
|
||||
|
||||
message = f"{mobile}:{purpose}:{code}".encode()
|
||||
return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def generate_refresh_token() -> str:
|
||||
"""Generate an opaque refresh token suitable for returning to clients."""
|
||||
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def hash_refresh_token(token: str) -> str:
|
||||
"""Hash a refresh token before persistence so plaintext tokens are never stored."""
|
||||
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
class JwtTokenCodec:
|
||||
"""Encode and validate short-lived access JWTs for authenticated gRPC calls."""
|
||||
|
||||
def __init__(self, secret_key: str, issuer: str, access_ttl_seconds: int) -> None:
|
||||
"""Store signing configuration used for all access-token operations."""
|
||||
self._secret_key = secret_key
|
||||
self._issuer = issuer
|
||||
self._access_ttl_seconds = access_ttl_seconds
|
||||
|
||||
@property
|
||||
def access_ttl_seconds(self) -> int:
|
||||
"""Return the configured access-token TTL exposed to clients."""
|
||||
|
||||
return self._access_ttl_seconds
|
||||
|
||||
def encode_access_token(self, user_id: str, role: Role) -> str:
|
||||
"""Create a signed access JWT containing user identity, role, and expiry."""
|
||||
|
||||
now = utc_now()
|
||||
expires_at = now + timedelta(seconds=self._access_ttl_seconds)
|
||||
payload = {
|
||||
@@ -56,6 +73,8 @@ class JwtTokenCodec:
|
||||
return jwt.encode(payload, self._secret_key, algorithm="HS256")
|
||||
|
||||
def decode_access_token(self, token: str) -> AccessClaims:
|
||||
"""Validate an access JWT and return typed claims used by RBAC checks."""
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
@@ -80,4 +99,3 @@ class JwtTokenCodec:
|
||||
role=role,
|
||||
expires_at=datetime.fromtimestamp(int(payload["exp"]), UTC),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Environment-driven settings shared by auth, worker, and demo services."""
|
||||
|
||||
app_env: str = "local"
|
||||
|
||||
mongo_uri: str = "mongodb://localhost:27017"
|
||||
@@ -46,4 +48,6 @@ class Settings(BaseSettings):
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached process settings loaded from environment and optional .env files."""
|
||||
|
||||
return Settings()
|
||||
|
||||
@@ -4,12 +4,16 @@ from enum import StrEnum
|
||||
|
||||
|
||||
class Role(StrEnum):
|
||||
"""User roles used by the auth service for access-control checks."""
|
||||
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class User:
|
||||
"""Persisted account identity created after a successful OTP verification."""
|
||||
|
||||
id: str
|
||||
mobile: str
|
||||
role: Role
|
||||
@@ -20,6 +24,8 @@ class User:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RefreshSession:
|
||||
"""Persisted refresh-token session stored as a token hash, never plaintext."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
token_hash: str
|
||||
@@ -31,6 +37,8 @@ class RefreshSession:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmsJob:
|
||||
"""RabbitMQ payload for delivering an OTP through the configured SMS provider."""
|
||||
|
||||
mobile: str
|
||||
code: str
|
||||
template: str
|
||||
@@ -39,6 +47,8 @@ class SmsJob:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TokenPair:
|
||||
"""Access and refresh tokens returned to a client after login or refresh."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
@@ -48,7 +58,8 @@ class TokenPair:
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccessClaims:
|
||||
"""Trusted claims extracted from a validated access JWT."""
|
||||
|
||||
user_id: str
|
||||
role: Role
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
@@ -3,36 +3,54 @@ class AppError(Exception):
|
||||
|
||||
|
||||
class RateLimitExceeded(AppError):
|
||||
"""Raised when OTP request or verification limits are exceeded."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
"""Raised when client input cannot be accepted by the application layer."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidOtp(AppError):
|
||||
"""Raised when an OTP exists but the submitted code does not match."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OtpExpired(AppError):
|
||||
"""Raised when an OTP is missing because it expired or was never requested."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OtpAttemptsExceeded(AppError):
|
||||
"""Raised when OTP verification attempts exceed the configured limit."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(AppError):
|
||||
"""Raised when credentials or tokens cannot authenticate a caller."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PermissionDenied(AppError):
|
||||
"""Raised when an authenticated caller lacks the required role."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InactiveUser(AppError):
|
||||
"""Raised when an existing user is disabled and cannot authenticate."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ExternalServiceError(AppError):
|
||||
"""Raised when an external provider fails or returns an error response."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -5,29 +5,51 @@ from gapido_auth.domain.entities import RefreshSession, Role, SmsJob, User
|
||||
|
||||
|
||||
class UserRepository(Protocol):
|
||||
async def get_by_id(self, user_id: str) -> User | None: ...
|
||||
"""Persistence port for user lookup, creation, and admin bootstrapping."""
|
||||
|
||||
async def get_by_mobile(self, mobile: str) -> User | None: ...
|
||||
async def get_by_id(self, user_id: str) -> User | None:
|
||||
"""Return a user by internal id, or None when it does not exist."""
|
||||
...
|
||||
|
||||
async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User: ...
|
||||
async def get_by_mobile(self, mobile: str) -> User | None:
|
||||
"""Return a user by mobile number, or None when it does not exist."""
|
||||
...
|
||||
|
||||
async def ensure_admin_user(self, mobile: str) -> User: ...
|
||||
async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User:
|
||||
"""Return the existing user for a mobile or create one with the default role."""
|
||||
...
|
||||
|
||||
async def ensure_admin_user(self, mobile: str) -> User:
|
||||
"""Seed or promote the configured admin mobile idempotently."""
|
||||
...
|
||||
|
||||
|
||||
class RefreshSessionRepository(Protocol):
|
||||
"""Persistence port for refresh-token session creation and rotation."""
|
||||
|
||||
async def create(
|
||||
self, user_id: str, token_hash: str, expires_at: datetime
|
||||
) -> RefreshSession: ...
|
||||
) -> RefreshSession:
|
||||
"""Persist a hashed refresh-token session."""
|
||||
...
|
||||
|
||||
async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None: ...
|
||||
async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None:
|
||||
"""Return a non-expired active refresh session by token hash."""
|
||||
...
|
||||
|
||||
async def revoke(
|
||||
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
||||
) -> None: ...
|
||||
) -> None:
|
||||
"""Mark a refresh session as revoked, optionally linking its replacement."""
|
||||
...
|
||||
|
||||
|
||||
class OtpStore(Protocol):
|
||||
async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool: ...
|
||||
"""Temporary OTP storage port with TTL, attempt, and rate-limit behavior."""
|
||||
|
||||
async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool:
|
||||
"""Check and increment a named rate-limit bucket."""
|
||||
...
|
||||
|
||||
async def store_otp(
|
||||
self,
|
||||
@@ -36,14 +58,26 @@ class OtpStore(Protocol):
|
||||
otp_hash: str,
|
||||
ttl_seconds: int,
|
||||
max_attempts: int,
|
||||
) -> None: ...
|
||||
) -> None:
|
||||
"""Store a hashed OTP with TTL and attempt policy."""
|
||||
...
|
||||
|
||||
async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool: ...
|
||||
async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool:
|
||||
"""Verify an OTP hash and apply attempt/expiry behavior."""
|
||||
...
|
||||
|
||||
|
||||
class SmsPublisher(Protocol):
|
||||
async def publish(self, job: SmsJob) -> None: ...
|
||||
"""Queue publishing port used by auth use cases to request SMS delivery."""
|
||||
|
||||
async def publish(self, job: SmsJob) -> None:
|
||||
"""Publish an OTP SMS job to the messaging boundary."""
|
||||
...
|
||||
|
||||
|
||||
class SmsClient(Protocol):
|
||||
async def send_otp(self, mobile: str, code: str, template: str) -> None: ...
|
||||
"""Provider strategy port implemented by Kavenegar, SMS.ir, and debug SMS."""
|
||||
|
||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
||||
"""Send an OTP through a concrete SMS provider."""
|
||||
...
|
||||
|
||||
@@ -19,12 +19,16 @@ from gapido_auth.infrastructure.sms_provider import get_sms_template
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppContainer:
|
||||
"""Runtime dependencies that need explicit shutdown after the gRPC server stops."""
|
||||
|
||||
auth_service: AuthService
|
||||
mongo_client: AsyncIOMotorClient[Any]
|
||||
redis: Redis
|
||||
|
||||
|
||||
async def build_auth_service(settings: Settings, rabbitmq_channel: AbstractChannel) -> AppContainer:
|
||||
"""Create repositories, adapters, policies, and the AuthService use-case object."""
|
||||
|
||||
mongo_client: AsyncIOMotorClient[Any] = AsyncIOMotorClient(settings.mongo_uri)
|
||||
db = mongo_client[settings.mongo_db_name]
|
||||
users = MongoUserRepository(db)
|
||||
|
||||
@@ -7,20 +7,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DebugSmsStore(Protocol):
|
||||
async def setex(self, name: str, time: int, value: str) -> object: ...
|
||||
"""Minimal Redis-like store used by the local debug SMS provider."""
|
||||
|
||||
async def setex(self, name: str, time: int, value: str) -> object:
|
||||
"""Store a value with a TTL using the Redis-compatible signature."""
|
||||
...
|
||||
|
||||
|
||||
class DebugSmsClient(SmsClient):
|
||||
"""Local-only SMS strategy that stores the latest OTP for demo retrieval."""
|
||||
|
||||
def __init__(self, store: DebugSmsStore, ttl_seconds: int) -> None:
|
||||
"""Configure the Redis-like store and short OTP debug retention."""
|
||||
self._store = store
|
||||
self._ttl_seconds = ttl_seconds
|
||||
|
||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
||||
"""Persist the OTP under a short-lived debug key instead of sending real SMS."""
|
||||
|
||||
key = debug_sms_key(mobile)
|
||||
await self._store.setex(key, self._ttl_seconds, code)
|
||||
logger.info("Debug SMS stored for mobile=%s template=%s", mobile, template)
|
||||
|
||||
|
||||
def debug_sms_key(mobile: str) -> str:
|
||||
return f"debug:sms:last:{mobile}"
|
||||
"""Return the Redis key used by the demo UI to read the latest local OTP."""
|
||||
|
||||
return f"debug:sms:last:{mobile}"
|
||||
|
||||
@@ -10,17 +10,22 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KavenegarSmsClient(SmsClient):
|
||||
"""Kavenegar verify/lookup implementation of the SMS provider strategy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
timeout_seconds: float = 10.0,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
"""Configure credentials, timeout, and optional test transport."""
|
||||
self._api_key = api_key
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._transport = transport
|
||||
|
||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
||||
"""Send an OTP through Kavenegar and raise on transport or API failure."""
|
||||
|
||||
url = f"https://api.kavenegar.com/v1/{self._api_key}/verify/lookup.json"
|
||||
payload = {"receptor": mobile, "template": template, "token": code, "type": "sms"}
|
||||
try:
|
||||
|
||||
@@ -10,10 +10,12 @@ from gapido_auth.domain.ports import RefreshSessionRepository, UserRepository
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""Return timezone-aware UTC now for Mongo document timestamps."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _user_from_doc(doc: dict[str, Any]) -> User:
|
||||
"""Map a Mongo user document into the domain entity."""
|
||||
return User(
|
||||
id=str(doc["_id"]),
|
||||
mobile=str(doc["mobile"]),
|
||||
@@ -25,6 +27,7 @@ def _user_from_doc(doc: dict[str, Any]) -> User:
|
||||
|
||||
|
||||
def _session_from_doc(doc: dict[str, Any]) -> RefreshSession:
|
||||
"""Map a Mongo refresh-session document into the domain entity."""
|
||||
return RefreshSession(
|
||||
id=str(doc["_id"]),
|
||||
user_id=str(doc["user_id"]),
|
||||
@@ -37,24 +40,35 @@ def _session_from_doc(doc: dict[str, Any]) -> RefreshSession:
|
||||
|
||||
|
||||
class MongoUserRepository(UserRepository):
|
||||
"""MongoDB adapter for user documents and admin bootstrap."""
|
||||
|
||||
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
||||
"""Bind the repository to the users collection."""
|
||||
self._collection = db.users
|
||||
|
||||
async def create_indexes(self) -> None:
|
||||
"""Create indexes required for unique mobile lookup and role scans."""
|
||||
|
||||
await self._collection.create_index([("mobile", ASCENDING)], unique=True)
|
||||
await self._collection.create_index([("role", ASCENDING)])
|
||||
|
||||
async def get_by_id(self, user_id: str) -> User | None:
|
||||
"""Return a user by Mongo ObjectId string, or None for invalid/missing ids."""
|
||||
|
||||
if not ObjectId.is_valid(user_id):
|
||||
return None
|
||||
doc = await self._collection.find_one({"_id": ObjectId(user_id)})
|
||||
return _user_from_doc(doc) if doc else None
|
||||
|
||||
async def get_by_mobile(self, mobile: str) -> User | None:
|
||||
"""Return the user registered for a mobile number if one exists."""
|
||||
|
||||
doc = await self._collection.find_one({"mobile": mobile})
|
||||
return _user_from_doc(doc) if doc else None
|
||||
|
||||
async def get_or_create_user(self, mobile: str, role: Role = Role.USER) -> User:
|
||||
"""Create a default active user for a verified mobile, or return the existing one."""
|
||||
|
||||
now = _now()
|
||||
await self._collection.update_one(
|
||||
{"mobile": mobile},
|
||||
@@ -75,6 +89,8 @@ class MongoUserRepository(UserRepository):
|
||||
return user
|
||||
|
||||
async def ensure_admin_user(self, mobile: str) -> User:
|
||||
"""Idempotently seed or promote the configured admin mobile."""
|
||||
|
||||
now = _now()
|
||||
await self._collection.update_one(
|
||||
{"mobile": mobile},
|
||||
@@ -91,15 +107,22 @@ class MongoUserRepository(UserRepository):
|
||||
|
||||
|
||||
class MongoRefreshSessionRepository(RefreshSessionRepository):
|
||||
"""MongoDB adapter for hashed refresh-token sessions."""
|
||||
|
||||
def __init__(self, db: AsyncIOMotorDatabase[Any]) -> None:
|
||||
"""Bind the repository to the refresh_sessions collection."""
|
||||
self._collection = db.refresh_sessions
|
||||
|
||||
async def create_indexes(self) -> None:
|
||||
"""Create indexes used for token lookup, user session scans, and revocation."""
|
||||
|
||||
await self._collection.create_index([("token_hash", ASCENDING)], unique=True)
|
||||
await self._collection.create_index([("user_id", ASCENDING), ("expires_at", ASCENDING)])
|
||||
await self._collection.create_index([("revoked_at", ASCENDING)])
|
||||
|
||||
async def create(self, user_id: str, token_hash: str, expires_at: datetime) -> RefreshSession:
|
||||
"""Persist a new active refresh session for a user."""
|
||||
|
||||
now = _now()
|
||||
result = await self._collection.insert_one(
|
||||
{
|
||||
@@ -117,6 +140,8 @@ class MongoRefreshSessionRepository(RefreshSessionRepository):
|
||||
return _session_from_doc(doc)
|
||||
|
||||
async def get_active_by_hash(self, token_hash: str, now: datetime) -> RefreshSession | None:
|
||||
"""Return a non-revoked, non-expired session by token hash."""
|
||||
|
||||
doc = await self._collection.find_one(
|
||||
{"token_hash": token_hash, "revoked_at": None, "expires_at": {"$gt": now}}
|
||||
)
|
||||
@@ -125,6 +150,8 @@ class MongoRefreshSessionRepository(RefreshSessionRepository):
|
||||
async def revoke(
|
||||
self, token_hash: str, now: datetime, replaced_by_hash: str | None = None
|
||||
) -> None:
|
||||
"""Mark a refresh session revoked, optionally linking its replacement hash."""
|
||||
|
||||
update: dict[str, Any] = {"revoked_at": now}
|
||||
if replaced_by_hash is not None:
|
||||
update["replaced_by_hash"] = replaced_by_hash
|
||||
@@ -132,4 +159,3 @@ class MongoRefreshSessionRepository(RefreshSessionRepository):
|
||||
{"token_hash": token_hash, "revoked_at": None},
|
||||
{"$set": update},
|
||||
)
|
||||
|
||||
|
||||
@@ -16,10 +16,14 @@ SMS_DLX = "gapido.sms.dlx"
|
||||
|
||||
|
||||
async def connect_robust(url: str) -> AbstractRobustConnection:
|
||||
"""Open a reconnecting RabbitMQ connection for publishers and workers."""
|
||||
|
||||
return await aio_pika.connect_robust(url)
|
||||
|
||||
|
||||
async def declare_sms_topology(channel: AbstractChannel) -> tuple[Any, AbstractQueue, Any]:
|
||||
"""Declare durable SMS exchange, queue, and dead-letter queue topology."""
|
||||
|
||||
exchange = await channel.declare_exchange(
|
||||
SMS_EXCHANGE, aio_pika.ExchangeType.DIRECT, durable=True
|
||||
)
|
||||
@@ -36,11 +40,16 @@ async def declare_sms_topology(channel: AbstractChannel) -> tuple[Any, AbstractQ
|
||||
|
||||
|
||||
class RabbitMqSmsPublisher(SmsPublisher):
|
||||
"""RabbitMQ publisher that sends durable OTP SMS jobs to the worker queue."""
|
||||
|
||||
def __init__(self, channel: AbstractChannel) -> None:
|
||||
"""Store the channel used for SMS job publishing."""
|
||||
self._channel = channel
|
||||
self._exchange: Any | None = None
|
||||
|
||||
async def publish(self, job: SmsJob) -> None:
|
||||
"""Serialize and publish an OTP SMS job to the configured routing key."""
|
||||
|
||||
if self._exchange is None:
|
||||
self._exchange, _, _ = await declare_sms_topology(self._channel)
|
||||
|
||||
|
||||
@@ -7,10 +7,15 @@ from gapido_auth.domain.ports import OtpStore
|
||||
|
||||
|
||||
class RedisOtpStore(OtpStore):
|
||||
"""Redis adapter for OTP hashes, verification attempts, and request throttling."""
|
||||
|
||||
def __init__(self, redis: Redis) -> None:
|
||||
"""Bind the store to an async Redis client."""
|
||||
self._redis = redis
|
||||
|
||||
async def allow_request(self, key: str, limit: int, window_seconds: int) -> bool:
|
||||
"""Increment a rate-limit counter and report whether it remains within limit."""
|
||||
|
||||
count = await self._redis.incr(key)
|
||||
if count == 1:
|
||||
await self._redis.expire(key, window_seconds)
|
||||
@@ -24,6 +29,8 @@ class RedisOtpStore(OtpStore):
|
||||
ttl_seconds: int,
|
||||
max_attempts: int,
|
||||
) -> None:
|
||||
"""Store a hashed OTP and reset its attempt counter with the same TTL window."""
|
||||
|
||||
key = self._otp_key(mobile, purpose)
|
||||
attempts_key = self._attempts_key(mobile, purpose)
|
||||
async with self._redis.pipeline(transaction=True) as pipe:
|
||||
@@ -34,6 +41,8 @@ class RedisOtpStore(OtpStore):
|
||||
await pipe.execute()
|
||||
|
||||
async def verify_otp(self, mobile: str, purpose: str, candidate_hash: str) -> bool:
|
||||
"""Compare a submitted OTP hash and delete state on success or exhausted attempts."""
|
||||
|
||||
key = self._otp_key(mobile, purpose)
|
||||
attempts_key = self._attempts_key(mobile, purpose)
|
||||
|
||||
@@ -60,8 +69,10 @@ class RedisOtpStore(OtpStore):
|
||||
|
||||
@staticmethod
|
||||
def _otp_key(mobile: str, purpose: str) -> str:
|
||||
"""Return the Redis hash key for an OTP challenge."""
|
||||
return f"otp:{mobile}:{purpose}"
|
||||
|
||||
@staticmethod
|
||||
def _attempts_key(mobile: str, purpose: str) -> str:
|
||||
"""Return the Redis counter key for OTP verification attempts."""
|
||||
return f"otp-attempts:{mobile}:{purpose}"
|
||||
|
||||
@@ -10,6 +10,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmsIrSmsClient(SmsClient):
|
||||
"""SMS.ir verify API implementation of the SMS provider strategy."""
|
||||
|
||||
_endpoint = "https://api.sms.ir/v1/send/verify"
|
||||
|
||||
def __init__(
|
||||
@@ -18,11 +20,14 @@ class SmsIrSmsClient(SmsClient):
|
||||
timeout_seconds: float = 10.0,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> None:
|
||||
"""Configure credentials, timeout, and optional test transport."""
|
||||
self._api_key = api_key
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._transport = transport
|
||||
|
||||
async def send_otp(self, mobile: str, code: str, template: str) -> None:
|
||||
"""Send an OTP through SMS.ir using the configured verify template id."""
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
@@ -53,4 +58,3 @@ class SmsIrSmsClient(SmsClient):
|
||||
raise ExternalServiceError("SMS.ir API error")
|
||||
|
||||
logger.info("OTP SMS sent successfully to mobile=%s via SMS.ir", mobile)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ def create_sms_client(
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
debug_store: DebugSmsStore | None = None,
|
||||
) -> SmsClient:
|
||||
"""Build the configured SMS provider strategy for the worker process."""
|
||||
|
||||
match settings.sms_provider:
|
||||
case "kavenegar":
|
||||
return KavenegarSmsClient(settings.kavenegar_api_key, transport=transport)
|
||||
@@ -24,6 +26,8 @@ def create_sms_client(
|
||||
|
||||
|
||||
def get_sms_template(settings: Settings) -> str:
|
||||
"""Return the provider-specific OTP template identifier used in queued SMS jobs."""
|
||||
|
||||
match settings.sms_provider:
|
||||
case "kavenegar":
|
||||
return settings.kavenegar_login_template
|
||||
|
||||
@@ -27,6 +27,8 @@ async def handle_message(
|
||||
exchange: Any,
|
||||
dlx: Any,
|
||||
) -> None:
|
||||
"""Process one SMS job, retry bounded failures, and dead-letter permanent failures."""
|
||||
|
||||
async with message.process(ignore_processed=True, requeue=False):
|
||||
payload = json.loads(message.body.decode())
|
||||
job = SmsJob(**payload)
|
||||
@@ -48,6 +50,7 @@ async def handle_message(
|
||||
)
|
||||
return
|
||||
|
||||
# Re-publish instead of requeueing indefinitely so retries remain bounded.
|
||||
logger.warning(
|
||||
"SMS job failed for mobile=%s retry=%s", job.mobile, retry_count + 1, exc_info=True
|
||||
)
|
||||
@@ -63,6 +66,8 @@ async def handle_message(
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Start the RabbitMQ consumer and bind it to the configured SMS strategy."""
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
settings = get_settings()
|
||||
connection = await connect_robust(settings.rabbitmq_url)
|
||||
|
||||
@@ -4,6 +4,7 @@ from grpc_tools import protoc
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Regenerate Python gRPC stubs from the checked-in auth protobuf file."""
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
proto_root = root / "proto"
|
||||
src_root = root / "src"
|
||||
@@ -23,4 +24,3 @@ def main() -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@@ -23,10 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
"""gRPC transport adapter that maps protobuf calls to AuthService use cases."""
|
||||
|
||||
def __init__(self, auth_service: AuthService) -> None:
|
||||
"""Bind the servicer to the application auth service."""
|
||||
self._auth_service = auth_service
|
||||
|
||||
async def RequestOtp(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Handle public OTP request calls."""
|
||||
|
||||
try:
|
||||
await self._auth_service.request_otp(
|
||||
mobile=request.mobile,
|
||||
@@ -38,6 +43,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
await _abort_for_app_error(context, exc)
|
||||
|
||||
async def VerifyOtp(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Handle public OTP verification and token issuance calls."""
|
||||
|
||||
try:
|
||||
token_pair = await self._auth_service.verify_otp(
|
||||
mobile=request.mobile,
|
||||
@@ -49,6 +56,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
await _abort_for_app_error(context, exc)
|
||||
|
||||
async def RefreshToken(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Handle refresh-token rotation calls."""
|
||||
|
||||
try:
|
||||
token_pair = await self._auth_service.refresh_token(request.refresh_token)
|
||||
return _token_response(token_pair)
|
||||
@@ -56,6 +65,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
await _abort_for_app_error(context, exc)
|
||||
|
||||
async def RevokeRefreshToken(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Handle authenticated refresh-token revocation calls."""
|
||||
|
||||
try:
|
||||
token = _extract_bearer_token(context.invocation_metadata())
|
||||
await self._auth_service.get_authenticated_user(token)
|
||||
@@ -65,9 +76,13 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
await _abort_for_app_error(context, exc)
|
||||
|
||||
async def PublicPing(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Return a public response without authentication."""
|
||||
|
||||
return auth_pb2.PingResponse(message="public ok")
|
||||
|
||||
async def UserOnly(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Return a response for any active authenticated user."""
|
||||
|
||||
try:
|
||||
user = await self._auth_service.get_authenticated_user(
|
||||
_extract_bearer_token(context.invocation_metadata())
|
||||
@@ -77,6 +92,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
await _abort_for_app_error(context, exc)
|
||||
|
||||
async def AdminOnly(self, request, context): # type: ignore[no-untyped-def]
|
||||
"""Return a response only for authenticated admin users."""
|
||||
|
||||
try:
|
||||
user = await self._auth_service.require_role(
|
||||
_extract_bearer_token(context.invocation_metadata()), Role.ADMIN
|
||||
@@ -87,6 +104,8 @@ class AuthGrpcServicer(auth_pb2_grpc.AuthServiceServicer):
|
||||
|
||||
|
||||
def _token_response(token_pair: TokenPair): # type: ignore[no-untyped-def]
|
||||
"""Convert an application token pair into a protobuf response."""
|
||||
|
||||
return auth_pb2.TokenResponse(
|
||||
access_token=token_pair.access_token,
|
||||
refresh_token=token_pair.refresh_token,
|
||||
@@ -97,10 +116,14 @@ def _token_response(token_pair: TokenPair): # type: ignore[no-untyped-def]
|
||||
|
||||
|
||||
def _protected_response(user: User, message: str): # type: ignore[no-untyped-def]
|
||||
"""Convert an authenticated user into a protected-method protobuf response."""
|
||||
|
||||
return auth_pb2.ProtectedResponse(user_id=user.id, role=user.role.value, message=message)
|
||||
|
||||
|
||||
def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str:
|
||||
"""Read bearer token metadata from a protected gRPC invocation."""
|
||||
|
||||
for key, value in metadata:
|
||||
if key.lower() == "authorization" and value.startswith("Bearer "):
|
||||
return value.removeprefix("Bearer ").strip()
|
||||
@@ -108,6 +131,8 @@ def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str:
|
||||
|
||||
|
||||
async def _abort_for_app_error(context: grpc.aio.ServicerContext, exc: AppError) -> None:
|
||||
"""Translate expected application errors into meaningful gRPC status codes."""
|
||||
|
||||
if isinstance(exc, AuthenticationError | InvalidOtp | OtpExpired | OtpAttemptsExceeded):
|
||||
await context.abort(grpc.StatusCode.UNAUTHENTICATED, str(exc))
|
||||
if isinstance(exc, InactiveUser):
|
||||
|
||||
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def serve() -> None:
|
||||
"""Start the async gRPC auth server and own infrastructure lifecycle."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
settings = get_settings()
|
||||
rabbitmq = await connect_robust(settings.rabbitmq_url)
|
||||
@@ -26,6 +27,7 @@ async def serve() -> None:
|
||||
auth_servicer = AuthGrpcServicer(container.auth_service)
|
||||
auth_pb2_grpc.add_AuthServiceServicer_to_server(auth_servicer, server)
|
||||
|
||||
# Health and reflection make the service easier to inspect with grpcurl.
|
||||
health_servicer = health.HealthServicer()
|
||||
health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
|
||||
service_names = (
|
||||
|
||||
@@ -20,46 +20,73 @@ T = TypeVar("T")
|
||||
|
||||
|
||||
class AuthClient(Protocol):
|
||||
async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: ...
|
||||
"""Minimal client contract used by FastAPI routes and tests."""
|
||||
|
||||
async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: ...
|
||||
async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]:
|
||||
"""Ask the auth service to create and dispatch an OTP."""
|
||||
...
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: ...
|
||||
async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse:
|
||||
"""Verify an OTP and return the token pair produced by auth-service."""
|
||||
...
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> DemoTokenResponse:
|
||||
"""Rotate a refresh token through auth-service."""
|
||||
...
|
||||
|
||||
async def revoke_refresh_token(
|
||||
self, access_token: str, refresh_token: str
|
||||
) -> dict[str, bool]: ...
|
||||
) -> dict[str, bool]:
|
||||
"""Revoke a refresh session using bearer-token metadata."""
|
||||
...
|
||||
|
||||
async def public_ping(self) -> dict[str, str]: ...
|
||||
async def public_ping(self) -> dict[str, str]:
|
||||
"""Call the public demo RPC without authentication."""
|
||||
...
|
||||
|
||||
async def user_only(self, access_token: str) -> dict[str, str]: ...
|
||||
async def user_only(self, access_token: str) -> dict[str, str]:
|
||||
"""Call the user-protected demo RPC with an access token."""
|
||||
...
|
||||
|
||||
async def admin_only(self, access_token: str) -> dict[str, str]: ...
|
||||
async def admin_only(self, access_token: str) -> dict[str, str]:
|
||||
"""Call the admin-protected demo RPC with an access token."""
|
||||
...
|
||||
|
||||
|
||||
class RequestOtpBody(BaseModel):
|
||||
"""HTTP body for starting the mobile OTP login flow."""
|
||||
|
||||
mobile: str = Field(min_length=10, max_length=16)
|
||||
purpose: str = "login"
|
||||
|
||||
|
||||
class VerifyOtpBody(RequestOtpBody):
|
||||
"""HTTP body for verifying a received OTP code."""
|
||||
|
||||
code: str = Field(min_length=6, max_length=6)
|
||||
|
||||
|
||||
class RefreshBody(BaseModel):
|
||||
"""HTTP body carrying the opaque refresh token."""
|
||||
|
||||
refresh_token: str = Field(min_length=20)
|
||||
|
||||
|
||||
class TokenBody(BaseModel):
|
||||
"""HTTP body carrying a bearer access token for demo actions."""
|
||||
|
||||
access_token: str = Field(min_length=20)
|
||||
|
||||
|
||||
class RevokeBody(TokenBody):
|
||||
"""HTTP body for revoking the current refresh session."""
|
||||
|
||||
refresh_token: str = Field(min_length=20)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Create and close shared outbound clients for the demo service."""
|
||||
settings = get_settings()
|
||||
app.state.settings = settings
|
||||
app.state.auth_client = AuthGrpcClient(settings.auth_grpc_target)
|
||||
@@ -81,24 +108,29 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
def get_auth_client(request: Request) -> AuthClient:
|
||||
"""Resolve the configured auth-service client from application state."""
|
||||
return cast(AuthClient, request.app.state.auth_client)
|
||||
|
||||
|
||||
def get_app_settings(request: Request) -> Settings:
|
||||
"""Resolve immutable runtime settings for request handlers."""
|
||||
return cast(Settings, request.app.state.settings)
|
||||
|
||||
|
||||
def get_debug_redis(request: Request) -> Redis | None:
|
||||
"""Resolve the optional Redis client used only by local debug OTP mode."""
|
||||
return cast(Redis | None, request.app.state.debug_redis)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
"""Serve the single-page browser demo."""
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Return a lightweight readiness response for Compose and Caddy checks."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -107,6 +139,7 @@ async def request_otp(
|
||||
body: RequestOtpBody,
|
||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||
) -> dict[str, object]:
|
||||
"""Forward an OTP request from the browser to the gRPC auth service."""
|
||||
return cast(
|
||||
dict[str, object],
|
||||
await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)),
|
||||
@@ -118,6 +151,7 @@ async def verify_otp(
|
||||
body: VerifyOtpBody,
|
||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||
) -> dict[str, object]:
|
||||
"""Verify an OTP and return token data to the browser demo."""
|
||||
response = await _call_grpc(lambda: client.verify_otp(body.mobile, body.code, body.purpose))
|
||||
return cast(dict[str, object], asdict(response))
|
||||
|
||||
@@ -127,6 +161,7 @@ async def refresh_token(
|
||||
body: RefreshBody,
|
||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||
) -> dict[str, object]:
|
||||
"""Rotate a refresh token and return a new token pair."""
|
||||
response = await _call_grpc(lambda: client.refresh_token(body.refresh_token))
|
||||
return cast(dict[str, object], asdict(response))
|
||||
|
||||
@@ -136,6 +171,7 @@ async def revoke_refresh_token(
|
||||
body: RevokeBody,
|
||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||
) -> dict[str, object]:
|
||||
"""Revoke the current refresh session through auth-service."""
|
||||
return cast(
|
||||
dict[str, object],
|
||||
await _call_grpc(
|
||||
@@ -146,6 +182,7 @@ async def revoke_refresh_token(
|
||||
|
||||
@app.post("/api/demo/public")
|
||||
async def public_demo(client: Annotated[AuthClient, Depends(get_auth_client)]) -> dict[str, object]:
|
||||
"""Call the public gRPC endpoint to prove unauthenticated access."""
|
||||
return cast(dict[str, object], await _call_grpc(client.public_ping))
|
||||
|
||||
|
||||
@@ -154,6 +191,7 @@ async def user_demo(
|
||||
body: TokenBody,
|
||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||
) -> dict[str, object]:
|
||||
"""Call the user-protected gRPC endpoint with bearer metadata."""
|
||||
return cast(dict[str, object], await _call_grpc(lambda: client.user_only(body.access_token)))
|
||||
|
||||
|
||||
@@ -162,6 +200,7 @@ async def admin_demo(
|
||||
body: TokenBody,
|
||||
client: Annotated[AuthClient, Depends(get_auth_client)],
|
||||
) -> dict[str, object]:
|
||||
"""Call the admin-protected gRPC endpoint with bearer metadata."""
|
||||
return cast(dict[str, object], await _call_grpc(lambda: client.admin_only(body.access_token)))
|
||||
|
||||
|
||||
@@ -171,6 +210,7 @@ async def debug_otp(
|
||||
settings: Annotated[Settings, Depends(get_app_settings)],
|
||||
redis: Annotated[Redis | None, Depends(get_debug_redis)],
|
||||
) -> dict[str, str | None]:
|
||||
"""Expose the last debug OTP in local demo mode only."""
|
||||
if not settings.demo_enable_debug_otp or redis is None:
|
||||
raise HTTPException(status_code=404, detail="debug OTP endpoint is disabled")
|
||||
code = await redis.get(debug_sms_key(mobile))
|
||||
@@ -178,6 +218,7 @@ async def debug_otp(
|
||||
|
||||
|
||||
async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T:
|
||||
"""Execute a gRPC call and translate transport errors to HTTP errors."""
|
||||
try:
|
||||
return await call()
|
||||
except grpc.aio.AioRpcError as exc:
|
||||
@@ -187,6 +228,7 @@ async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T:
|
||||
|
||||
|
||||
def _grpc_to_http_status(code: grpc.StatusCode) -> int:
|
||||
"""Map auth-service gRPC status codes to browser-friendly HTTP statuses."""
|
||||
match code:
|
||||
case grpc.StatusCode.INVALID_ARGUMENT:
|
||||
return 400
|
||||
|
||||
@@ -9,6 +9,8 @@ from gapido_auth.generated import auth_pb2, auth_pb2_grpc
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DemoTokenResponse:
|
||||
"""Token payload shape returned by the demo BFF to the browser."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str
|
||||
@@ -17,32 +19,40 @@ class DemoTokenResponse:
|
||||
|
||||
|
||||
class AuthGrpcClient:
|
||||
"""Thin async gRPC client used by the FastAPI demo service."""
|
||||
|
||||
def __init__(self, target: str) -> None:
|
||||
"""Open an async channel to auth-service."""
|
||||
self._channel = grpc.aio.insecure_channel(target)
|
||||
self._stub = auth_pb2_grpc.AuthServiceStub(self._channel)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the underlying gRPC channel during FastAPI shutdown."""
|
||||
await self._channel.close()
|
||||
|
||||
async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]:
|
||||
"""Forward an OTP request and normalize the protobuf response."""
|
||||
response = await self._stub.RequestOtp(
|
||||
auth_pb2.RequestOtpRequest(mobile=mobile, purpose=purpose)
|
||||
)
|
||||
return {"accepted": bool(response.accepted)}
|
||||
|
||||
async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse:
|
||||
"""Verify an OTP and convert the protobuf token response."""
|
||||
response = await self._stub.VerifyOtp(
|
||||
auth_pb2.VerifyOtpRequest(mobile=mobile, code=code, purpose=purpose)
|
||||
)
|
||||
return _token_response(response)
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> DemoTokenResponse:
|
||||
"""Refresh and rotate an opaque refresh token."""
|
||||
response = await self._stub.RefreshToken(
|
||||
auth_pb2.RefreshTokenRequest(refresh_token=refresh_token)
|
||||
)
|
||||
return _token_response(response)
|
||||
|
||||
async def revoke_refresh_token(self, access_token: str, refresh_token: str) -> dict[str, bool]:
|
||||
"""Revoke a refresh token using access-token authorization metadata."""
|
||||
response = await self._stub.RevokeRefreshToken(
|
||||
auth_pb2.RevokeRefreshTokenRequest(refresh_token=refresh_token),
|
||||
metadata=_auth_metadata(access_token),
|
||||
@@ -50,16 +60,19 @@ class AuthGrpcClient:
|
||||
return {"revoked": bool(response.revoked)}
|
||||
|
||||
async def public_ping(self) -> dict[str, str]:
|
||||
"""Call the public demonstration endpoint."""
|
||||
response = await self._stub.PublicPing(auth_pb2.PingRequest())
|
||||
return {"message": str(response.message)}
|
||||
|
||||
async def user_only(self, access_token: str) -> dict[str, str]:
|
||||
"""Call the user-only demonstration endpoint."""
|
||||
response = await self._stub.UserOnly(
|
||||
auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token)
|
||||
)
|
||||
return _protected_response(response)
|
||||
|
||||
async def admin_only(self, access_token: str) -> dict[str, str]:
|
||||
"""Call the admin-only demonstration endpoint."""
|
||||
response = await self._stub.AdminOnly(
|
||||
auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token)
|
||||
)
|
||||
@@ -67,10 +80,12 @@ class AuthGrpcClient:
|
||||
|
||||
|
||||
def _auth_metadata(access_token: str) -> tuple[tuple[str, str], ...]:
|
||||
"""Build gRPC bearer metadata expected by auth-service."""
|
||||
return (("authorization", f"Bearer {access_token}"),)
|
||||
|
||||
|
||||
def _token_response(response: Any) -> DemoTokenResponse:
|
||||
"""Convert a protobuf token message into a dataclass."""
|
||||
return DemoTokenResponse(
|
||||
access_token=str(response.access_token),
|
||||
refresh_token=str(response.refresh_token),
|
||||
@@ -81,6 +96,7 @@ def _token_response(response: Any) -> DemoTokenResponse:
|
||||
|
||||
|
||||
def _protected_response(response: Any) -> dict[str, str]:
|
||||
"""Convert a protected protobuf response into a JSON-ready dict."""
|
||||
return {
|
||||
"user_id": str(response.user_id),
|
||||
"role": str(response.role),
|
||||
|
||||
Reference in New Issue
Block a user