diff --git a/proto/gapido_auth/generated/auth.proto b/proto/gapido_auth/generated/auth.proto new file mode 100644 index 0000000..ab629e0 --- /dev/null +++ b/proto/gapido_auth/generated/auth.proto @@ -0,0 +1,63 @@ +syntax = "proto3"; + +package gapido.auth.v1; + +service AuthService { + rpc RequestOtp (RequestOtpRequest) returns (RequestOtpResponse); + rpc VerifyOtp (VerifyOtpRequest) returns (TokenResponse); + rpc RefreshToken (RefreshTokenRequest) returns (TokenResponse); + rpc RevokeRefreshToken (RevokeRefreshTokenRequest) returns (RevokeRefreshTokenResponse); + rpc PublicPing (PingRequest) returns (PingResponse); + rpc UserOnly (ProtectedRequest) returns (ProtectedResponse); + rpc AdminOnly (ProtectedRequest) returns (ProtectedResponse); +} + +message RequestOtpRequest { + string mobile = 1; + string purpose = 2; +} + +message RequestOtpResponse { + bool accepted = 1; +} + +message VerifyOtpRequest { + string mobile = 1; + string code = 2; + string purpose = 3; +} + +message RefreshTokenRequest { + string refresh_token = 1; +} + +message RevokeRefreshTokenRequest { + string refresh_token = 1; +} + +message RevokeRefreshTokenResponse { + bool revoked = 1; +} + +message TokenResponse { + string access_token = 1; + string refresh_token = 2; + string token_type = 3; + int32 expires_in = 4; + string role = 5; +} + +message PingRequest {} + +message PingResponse { + string message = 1; +} + +message ProtectedRequest {} + +message ProtectedResponse { + string user_id = 1; + string role = 2; + string message = 3; +} + diff --git a/src/gapido_auth/generated/__init__.py b/src/gapido_auth/generated/__init__.py new file mode 100644 index 0000000..220dfab --- /dev/null +++ b/src/gapido_auth/generated/__init__.py @@ -0,0 +1,2 @@ +"""Generated protobuf modules are written here by gapido_auth.tools.generate_proto.""" + diff --git a/src/gapido_auth/generated/auth_pb2.py b/src/gapido_auth/generated/auth_pb2.py new file mode 100644 index 0000000..c419dcd --- /dev/null +++ b/src/gapido_auth/generated/auth_pb2.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: gapido_auth/generated/auth.proto +# Protobuf Python Version: 5.28.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 5, + 28, + 1, + '', + 'gapido_auth/generated/auth.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n gapido_auth/generated/auth.proto\x12\x0egapido.auth.v1\"4\n\x11RequestOtpRequest\x12\x0e\n\x06mobile\x18\x01 \x01(\t\x12\x0f\n\x07purpose\x18\x02 \x01(\t\"&\n\x12RequestOtpResponse\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x01 \x01(\x08\"A\n\x10VerifyOtpRequest\x12\x0e\n\x06mobile\x18\x01 \x01(\t\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0f\n\x07purpose\x18\x03 \x01(\t\",\n\x13RefreshTokenRequest\x12\x15\n\rrefresh_token\x18\x01 \x01(\t\"2\n\x19RevokeRefreshTokenRequest\x12\x15\n\rrefresh_token\x18\x01 \x01(\t\"-\n\x1aRevokeRefreshTokenResponse\x12\x0f\n\x07revoked\x18\x01 \x01(\x08\"r\n\rTokenResponse\x12\x14\n\x0c\x61\x63\x63\x65ss_token\x18\x01 \x01(\t\x12\x15\n\rrefresh_token\x18\x02 \x01(\t\x12\x12\n\ntoken_type\x18\x03 \x01(\t\x12\x12\n\nexpires_in\x18\x04 \x01(\x05\x12\x0c\n\x04role\x18\x05 \x01(\t\"\r\n\x0bPingRequest\"\x1f\n\x0cPingResponse\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x12\n\x10ProtectedRequest\"C\n\x11ProtectedResponse\x12\x0f\n\x07user_id\x18\x01 \x01(\t\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t2\xdd\x04\n\x0b\x41uthService\x12S\n\nRequestOtp\x12!.gapido.auth.v1.RequestOtpRequest\x1a\".gapido.auth.v1.RequestOtpResponse\x12L\n\tVerifyOtp\x12 .gapido.auth.v1.VerifyOtpRequest\x1a\x1d.gapido.auth.v1.TokenResponse\x12R\n\x0cRefreshToken\x12#.gapido.auth.v1.RefreshTokenRequest\x1a\x1d.gapido.auth.v1.TokenResponse\x12k\n\x12RevokeRefreshToken\x12).gapido.auth.v1.RevokeRefreshTokenRequest\x1a*.gapido.auth.v1.RevokeRefreshTokenResponse\x12G\n\nPublicPing\x12\x1b.gapido.auth.v1.PingRequest\x1a\x1c.gapido.auth.v1.PingResponse\x12O\n\x08UserOnly\x12 .gapido.auth.v1.ProtectedRequest\x1a!.gapido.auth.v1.ProtectedResponse\x12P\n\tAdminOnly\x12 .gapido.auth.v1.ProtectedRequest\x1a!.gapido.auth.v1.ProtectedResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'gapido_auth.generated.auth_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_REQUESTOTPREQUEST']._serialized_start=52 + _globals['_REQUESTOTPREQUEST']._serialized_end=104 + _globals['_REQUESTOTPRESPONSE']._serialized_start=106 + _globals['_REQUESTOTPRESPONSE']._serialized_end=144 + _globals['_VERIFYOTPREQUEST']._serialized_start=146 + _globals['_VERIFYOTPREQUEST']._serialized_end=211 + _globals['_REFRESHTOKENREQUEST']._serialized_start=213 + _globals['_REFRESHTOKENREQUEST']._serialized_end=257 + _globals['_REVOKEREFRESHTOKENREQUEST']._serialized_start=259 + _globals['_REVOKEREFRESHTOKENREQUEST']._serialized_end=309 + _globals['_REVOKEREFRESHTOKENRESPONSE']._serialized_start=311 + _globals['_REVOKEREFRESHTOKENRESPONSE']._serialized_end=356 + _globals['_TOKENRESPONSE']._serialized_start=358 + _globals['_TOKENRESPONSE']._serialized_end=472 + _globals['_PINGREQUEST']._serialized_start=474 + _globals['_PINGREQUEST']._serialized_end=487 + _globals['_PINGRESPONSE']._serialized_start=489 + _globals['_PINGRESPONSE']._serialized_end=520 + _globals['_PROTECTEDREQUEST']._serialized_start=522 + _globals['_PROTECTEDREQUEST']._serialized_end=540 + _globals['_PROTECTEDRESPONSE']._serialized_start=542 + _globals['_PROTECTEDRESPONSE']._serialized_end=609 + _globals['_AUTHSERVICE']._serialized_start=612 + _globals['_AUTHSERVICE']._serialized_end=1217 +# @@protoc_insertion_point(module_scope) diff --git a/src/gapido_auth/generated/auth_pb2_grpc.py b/src/gapido_auth/generated/auth_pb2_grpc.py new file mode 100644 index 0000000..8462909 --- /dev/null +++ b/src/gapido_auth/generated/auth_pb2_grpc.py @@ -0,0 +1,355 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from gapido_auth.generated import auth_pb2 as gapido__auth_dot_generated_dot_auth__pb2 + +GRPC_GENERATED_VERSION = '1.68.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + f' but the generated code in gapido_auth/generated/auth_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class AuthServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.RequestOtp = channel.unary_unary( + '/gapido.auth.v1.AuthService/RequestOtp', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.RequestOtpRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.RequestOtpResponse.FromString, + _registered_method=True) + self.VerifyOtp = channel.unary_unary( + '/gapido.auth.v1.AuthService/VerifyOtp', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.VerifyOtpRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.TokenResponse.FromString, + _registered_method=True) + self.RefreshToken = channel.unary_unary( + '/gapido.auth.v1.AuthService/RefreshToken', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.RefreshTokenRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.TokenResponse.FromString, + _registered_method=True) + self.RevokeRefreshToken = channel.unary_unary( + '/gapido.auth.v1.AuthService/RevokeRefreshToken', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.RevokeRefreshTokenRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.RevokeRefreshTokenResponse.FromString, + _registered_method=True) + self.PublicPing = channel.unary_unary( + '/gapido.auth.v1.AuthService/PublicPing', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.PingRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.PingResponse.FromString, + _registered_method=True) + self.UserOnly = channel.unary_unary( + '/gapido.auth.v1.AuthService/UserOnly', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedResponse.FromString, + _registered_method=True) + self.AdminOnly = channel.unary_unary( + '/gapido.auth.v1.AuthService/AdminOnly', + request_serializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedRequest.SerializeToString, + response_deserializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedResponse.FromString, + _registered_method=True) + + +class AuthServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def RequestOtp(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def VerifyOtp(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def RefreshToken(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def RevokeRefreshToken(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def PublicPing(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def UserOnly(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def AdminOnly(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_AuthServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'RequestOtp': grpc.unary_unary_rpc_method_handler( + servicer.RequestOtp, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.RequestOtpRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.RequestOtpResponse.SerializeToString, + ), + 'VerifyOtp': grpc.unary_unary_rpc_method_handler( + servicer.VerifyOtp, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.VerifyOtpRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.TokenResponse.SerializeToString, + ), + 'RefreshToken': grpc.unary_unary_rpc_method_handler( + servicer.RefreshToken, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.RefreshTokenRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.TokenResponse.SerializeToString, + ), + 'RevokeRefreshToken': grpc.unary_unary_rpc_method_handler( + servicer.RevokeRefreshToken, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.RevokeRefreshTokenRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.RevokeRefreshTokenResponse.SerializeToString, + ), + 'PublicPing': grpc.unary_unary_rpc_method_handler( + servicer.PublicPing, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.PingRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.PingResponse.SerializeToString, + ), + 'UserOnly': grpc.unary_unary_rpc_method_handler( + servicer.UserOnly, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedResponse.SerializeToString, + ), + 'AdminOnly': grpc.unary_unary_rpc_method_handler( + servicer.AdminOnly, + request_deserializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedRequest.FromString, + response_serializer=gapido__auth_dot_generated_dot_auth__pb2.ProtectedResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'gapido.auth.v1.AuthService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('gapido.auth.v1.AuthService', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class AuthService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def RequestOtp(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/RequestOtp', + gapido__auth_dot_generated_dot_auth__pb2.RequestOtpRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.RequestOtpResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def VerifyOtp(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/VerifyOtp', + gapido__auth_dot_generated_dot_auth__pb2.VerifyOtpRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.TokenResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def RefreshToken(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/RefreshToken', + gapido__auth_dot_generated_dot_auth__pb2.RefreshTokenRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.TokenResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def RevokeRefreshToken(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/RevokeRefreshToken', + gapido__auth_dot_generated_dot_auth__pb2.RevokeRefreshTokenRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.RevokeRefreshTokenResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def PublicPing(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/PublicPing', + gapido__auth_dot_generated_dot_auth__pb2.PingRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.PingResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def UserOnly(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/UserOnly', + gapido__auth_dot_generated_dot_auth__pb2.ProtectedRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.ProtectedResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def AdminOnly(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/gapido.auth.v1.AuthService/AdminOnly', + gapido__auth_dot_generated_dot_auth__pb2.ProtectedRequest.SerializeToString, + gapido__auth_dot_generated_dot_auth__pb2.ProtectedResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/src/gapido_auth/tools/__init__.py b/src/gapido_auth/tools/__init__.py new file mode 100644 index 0000000..ca61be6 --- /dev/null +++ b/src/gapido_auth/tools/__init__.py @@ -0,0 +1,2 @@ +"""Developer tooling.""" + diff --git a/src/gapido_auth/tools/generate_proto.py b/src/gapido_auth/tools/generate_proto.py new file mode 100644 index 0000000..61915a2 --- /dev/null +++ b/src/gapido_auth/tools/generate_proto.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from grpc_tools import protoc + + +def main() -> None: + root = Path(__file__).resolve().parents[3] + proto_root = root / "proto" + src_root = root / "src" + proto_file = proto_root / "gapido_auth" / "generated" / "auth.proto" + result = protoc.main( + [ + "grpc_tools.protoc", + f"-I{proto_root}", + f"--python_out={src_root}", + f"--grpc_python_out={src_root}", + str(proto_file), + ] + ) + if result != 0: + raise SystemExit(result) + + +if __name__ == "__main__": + main() + diff --git a/src/gapido_auth/transport/__init__.py b/src/gapido_auth/transport/__init__.py new file mode 100644 index 0000000..17bcb63 --- /dev/null +++ b/src/gapido_auth/transport/__init__.py @@ -0,0 +1,2 @@ +"""Transport adapters.""" + diff --git a/src/gapido_auth/transport/grpc/__init__.py b/src/gapido_auth/transport/grpc/__init__.py new file mode 100644 index 0000000..374fee7 --- /dev/null +++ b/src/gapido_auth/transport/grpc/__init__.py @@ -0,0 +1,2 @@ +"""gRPC transport.""" + diff --git a/src/gapido_auth/transport/grpc/auth_servicer.py b/src/gapido_auth/transport/grpc/auth_servicer.py new file mode 100644 index 0000000..a844c62 --- /dev/null +++ b/src/gapido_auth/transport/grpc/auth_servicer.py @@ -0,0 +1,122 @@ +# 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): + def __init__(self, auth_service: AuthService) -> None: + self._auth_service = auth_service + + async def RequestOtp(self, request, context): # type: ignore[no-untyped-def] + 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] + 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] + 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] + 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 auth_pb2.PingResponse(message="public ok") + + async def UserOnly(self, request, context): # type: ignore[no-untyped-def] + 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] + 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] + 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] + return auth_pb2.ProtectedResponse(user_id=user.id, role=user.role.value, message=message) + + +def _extract_bearer_token(metadata: Sequence[tuple[str, str]]) -> str: + 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: + 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") diff --git a/src/gapido_auth/transport/grpc/server.py b/src/gapido_auth/transport/grpc/server.py new file mode 100644 index 0000000..26934ba --- /dev/null +++ b/src/gapido_auth/transport/grpc/server.py @@ -0,0 +1,54 @@ +# mypy: disable-error-code="attr-defined,no-untyped-call" +import asyncio +import logging + +import grpc +from grpc_health.v1 import health, health_pb2, health_pb2_grpc +from grpc_reflection.v1alpha import reflection + +from gapido_auth.config import get_settings +from gapido_auth.generated import auth_pb2, auth_pb2_grpc +from gapido_auth.infrastructure.container import build_auth_service +from gapido_auth.infrastructure.rabbitmq import connect_robust +from gapido_auth.transport.grpc.auth_servicer import AuthGrpcServicer + +logger = logging.getLogger(__name__) + + +async def serve() -> None: + logging.basicConfig(level=logging.INFO) + settings = get_settings() + rabbitmq = await connect_robust(settings.rabbitmq_url) + channel = await rabbitmq.channel() + container = await build_auth_service(settings, channel) + + server = grpc.aio.server() + auth_servicer = AuthGrpcServicer(container.auth_service) + auth_pb2_grpc.add_AuthServiceServicer_to_server(auth_servicer, server) + + health_servicer = health.HealthServicer() + health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server) + service_names = ( + auth_pb2.DESCRIPTOR.services_by_name["AuthService"].full_name, + health.SERVICE_NAME, + reflection.SERVICE_NAME, + ) + reflection.enable_server_reflection(service_names, server) + health_servicer.set("", health_pb2.HealthCheckResponse.SERVING) + + listen_addr = f"{settings.grpc_host}:{settings.grpc_port}" + server.add_insecure_port(listen_addr) + await server.start() + logger.info("gRPC auth service started on %s", listen_addr) + + try: + await server.wait_for_termination() + finally: + await server.stop(grace=5) + await container.redis.aclose() + container.mongo_client.close() + await rabbitmq.close() + + +if __name__ == "__main__": + asyncio.run(serve())