148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
# mypy: disable-error-code="attr-defined"
|
|
import logging
|
|
from collections.abc import Sequence
|
|
|
|
import grpc
|
|
|
|
from gapido_auth.application.auth_service import AuthService
|
|
from gapido_auth.domain.entities import Role, TokenPair, User
|
|
from gapido_auth.domain.errors import (
|
|
AppError,
|
|
AuthenticationError,
|
|
InactiveUser,
|
|
InvalidOtp,
|
|
OtpAttemptsExceeded,
|
|
OtpExpired,
|
|
PermissionDenied,
|
|
RateLimitExceeded,
|
|
ValidationError,
|
|
)
|
|
from gapido_auth.generated import auth_pb2, auth_pb2_grpc
|
|
|
|
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,
|
|
purpose=request.purpose or "login",
|
|
client_key=context.peer(),
|
|
)
|
|
return auth_pb2.RequestOtpResponse(accepted=True)
|
|
except AppError as exc:
|
|
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,
|
|
code=request.code,
|
|
purpose=request.purpose or "login",
|
|
)
|
|
return _token_response(token_pair)
|
|
except AppError as exc:
|
|
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)
|
|
except AppError as exc:
|
|
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)
|
|
await self._auth_service.revoke_refresh_token(request.refresh_token)
|
|
return auth_pb2.RevokeRefreshTokenResponse(revoked=True)
|
|
except AppError as exc:
|
|
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())
|
|
)
|
|
return _protected_response(user, "authenticated user ok")
|
|
except AppError as exc:
|
|
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
|
|
)
|
|
return _protected_response(user, "admin ok")
|
|
except AppError as exc:
|
|
await _abort_for_app_error(context, exc)
|
|
|
|
|
|
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,
|
|
token_type=token_pair.token_type,
|
|
expires_in=token_pair.expires_in,
|
|
role=token_pair.role.value,
|
|
)
|
|
|
|
|
|
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()
|
|
raise AuthenticationError("missing bearer token")
|
|
|
|
|
|
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):
|
|
await context.abort(grpc.StatusCode.PERMISSION_DENIED, str(exc))
|
|
if isinstance(exc, PermissionDenied):
|
|
await context.abort(grpc.StatusCode.PERMISSION_DENIED, str(exc))
|
|
if isinstance(exc, RateLimitExceeded):
|
|
await context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, str(exc))
|
|
if isinstance(exc, ValidationError):
|
|
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc))
|
|
logger.exception("Unhandled application error")
|
|
await context.abort(grpc.StatusCode.INTERNAL, "internal error")
|