Microservice Auth Demo
+Gapido gRPC Auth
+Session
+-
+
- Role
- -
- Access TTL
- -
- Token
- -
Service Calls
+{ "ready": true }
+ diff --git a/.env.example b/.env.example index 62125ae..1056677 100644 --- a/.env.example +++ b/.env.example @@ -11,11 +11,16 @@ OTP_TTL_SECONDS=120 OTP_MAX_ATTEMPTS=5 OTP_REQUEST_LIMIT=3 OTP_REQUEST_WINDOW_SECONDS=300 -SMS_PROVIDER=kavenegar +SMS_PROVIDER=debug KAVENEGAR_API_KEY=replace-with-real-key KAVENEGAR_LOGIN_TEMPLATE=login-otp SMS_IR_API_KEY=replace-with-real-key SMS_IR_VERIFY_TEMPLATE_ID=570574 +AUTH_GRPC_TARGET=localhost:50051 +DEMO_ENABLE_DEBUG_OTP=true +DEMO_DEBUG_SMS_TTL_SECONDS=300 +DEMO_HOST=0.0.0.0 +DEMO_PORT=8080 ADMIN_MOBILE=989120000000 GRPC_HOST=0.0.0.0 GRPC_PORT=50051 diff --git a/README.md b/README.md index 832c81f..a18b5e2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ cp .env.example .env docker compose up --build ``` -The gRPC service listens on `localhost:50051`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`. +The gRPC service listens on `localhost:50051`. The demo UI is available at `http://localhost:8080`. RabbitMQ management is available at `http://localhost:15672` with `guest` / `guest`. ## SMS Provider @@ -31,6 +31,18 @@ Supported values: - `kavenegar`: uses `KAVENEGAR_API_KEY` and `KAVENEGAR_LOGIN_TEMPLATE`. - `sms_ir`: uses `SMS_IR_API_KEY` and `SMS_IR_VERIFY_TEMPLATE_ID`. +- `debug`: local-only provider that stores the latest OTP in Redis for the demo UI. + +## Demo UI + +`demo-app` is a small FastAPI backend-for-frontend that calls `auth-service` over gRPC. It demonstrates how another microservice consumes the auth service. + +Available browser actions: + +- Request and verify OTP. +- Fetch the local debug OTP when `DEMO_ENABLE_DEBUG_OTP=true`. +- Call public, authenticated-user, and admin-only gRPC methods. +- Refresh and revoke tokens. ## Local Development diff --git a/docker-compose.yml b/docker-compose.yml index b1536c1..2c30844 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: OTP_MAX_ATTEMPTS: ${OTP_MAX_ATTEMPTS:-5} OTP_REQUEST_LIMIT: ${OTP_REQUEST_LIMIT:-3} OTP_REQUEST_WINDOW_SECONDS: ${OTP_REQUEST_WINDOW_SECONDS:-300} - SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar} + SMS_PROVIDER: ${SMS_PROVIDER:-debug} KAVENEGAR_LOGIN_TEMPLATE: ${KAVENEGAR_LOGIN_TEMPLATE:-login-otp} SMS_IR_VERIFY_TEMPLATE_ID: ${SMS_IR_VERIFY_TEMPLATE_ID:-570574} ADMIN_MOBILE: ${ADMIN_MOBILE:-989120000000} @@ -38,13 +38,28 @@ services: MONGO_URI: mongodb://mongo:27017 REDIS_URL: redis://redis:6379/0 RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ - SMS_PROVIDER: ${SMS_PROVIDER:-kavenegar} + SMS_PROVIDER: ${SMS_PROVIDER:-debug} KAVENEGAR_API_KEY: ${KAVENEGAR_API_KEY:-replace-with-real-key} SMS_IR_API_KEY: ${SMS_IR_API_KEY:-replace-with-real-key} depends_on: rabbitmq: condition: service_healthy + demo-app: + build: . + command: uvicorn gapido_auth.demo.app:app --host 0.0.0.0 --port 8080 + environment: + AUTH_GRPC_TARGET: auth-service:50051 + REDIS_URL: redis://redis:6379/0 + DEMO_ENABLE_DEBUG_OTP: "true" + ports: + - "8080:8080" + depends_on: + auth-service: + condition: service_started + redis: + condition: service_started + mongo: image: mongo:7 ports: diff --git a/pyproject.toml b/pyproject.toml index 6b58a25..0be599c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Gapido backend code challenge: gRPC OTP auth service with MongoDB requires-python = ">=3.12,<3.14" dependencies = [ "aio-pika==9.5.5", + "fastapi==0.115.6", "grpcio==1.68.1", "grpcio-health-checking==1.68.1", "grpcio-reflection==1.68.1", @@ -16,6 +17,7 @@ dependencies = [ "pydantic-settings==2.7.1", "pyjwt==2.10.1", "redis==5.2.1", + "uvicorn==0.34.0", ] [project.optional-dependencies] diff --git a/src/gapido_auth/config.py b/src/gapido_auth/config.py index a8a3c5d..0d6778c 100644 --- a/src/gapido_auth/config.py +++ b/src/gapido_auth/config.py @@ -23,7 +23,7 @@ class Settings(BaseSettings): otp_request_limit: int = 3 otp_request_window_seconds: int = 300 - sms_provider: Literal["kavenegar", "sms_ir"] = "kavenegar" + sms_provider: Literal["kavenegar", "sms_ir", "debug"] = "kavenegar" kavenegar_api_key: str = "replace-with-real-key" kavenegar_login_template: str = "login-otp" @@ -31,6 +31,12 @@ class Settings(BaseSettings): sms_ir_api_key: str = "replace-with-real-key" sms_ir_verify_template_id: int = 570574 + auth_grpc_target: str = "localhost:50051" + demo_enable_debug_otp: bool = False + demo_debug_sms_ttl_seconds: int = 300 + demo_host: str = "0.0.0.0" + demo_port: int = 8080 + admin_mobile: str = "989120000000" grpc_host: str = "0.0.0.0" grpc_port: int = 50051 diff --git a/src/gapido_auth/demo/__init__.py b/src/gapido_auth/demo/__init__.py new file mode 100644 index 0000000..73e7137 --- /dev/null +++ b/src/gapido_auth/demo/__init__.py @@ -0,0 +1,2 @@ +"""Demo browser client for the auth service.""" + diff --git a/src/gapido_auth/demo/app.py b/src/gapido_auth/demo/app.py new file mode 100644 index 0000000..b0c15e2 --- /dev/null +++ b/src/gapido_auth/demo/app.py @@ -0,0 +1,197 @@ +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import asdict +from pathlib import Path +from typing import Annotated, Protocol, TypeVar, cast + +import grpc +from fastapi import Depends, FastAPI, HTTPException, Query, Request +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field +from redis.asyncio import Redis + +from gapido_auth.config import Settings, get_settings +from gapido_auth.demo.grpc_client import AuthGrpcClient, DemoTokenResponse +from gapido_auth.infrastructure.debug_sms_client import debug_sms_key + +STATIC_DIR = Path(__file__).resolve().parent / "static" +T = TypeVar("T") + + +class AuthClient(Protocol): + async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: ... + + async def verify_otp(self, mobile: str, code: str, purpose: str) -> DemoTokenResponse: ... + + async def refresh_token(self, refresh_token: str) -> DemoTokenResponse: ... + + async def revoke_refresh_token( + self, access_token: str, refresh_token: str + ) -> dict[str, bool]: ... + + async def public_ping(self) -> dict[str, str]: ... + + async def user_only(self, access_token: str) -> dict[str, str]: ... + + async def admin_only(self, access_token: str) -> dict[str, str]: ... + + +class RequestOtpBody(BaseModel): + mobile: str = Field(min_length=10, max_length=16) + purpose: str = "login" + + +class VerifyOtpBody(RequestOtpBody): + code: str = Field(min_length=6, max_length=6) + + +class RefreshBody(BaseModel): + refresh_token: str = Field(min_length=20) + + +class TokenBody(BaseModel): + access_token: str = Field(min_length=20) + + +class RevokeBody(TokenBody): + refresh_token: str = Field(min_length=20) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + app.state.settings = settings + app.state.auth_client = AuthGrpcClient(settings.auth_grpc_target) + app.state.debug_redis = ( + Redis.from_url(settings.redis_url, decode_responses=True) + if settings.demo_enable_debug_otp + else None + ) + try: + yield + finally: + await app.state.auth_client.close() + if app.state.debug_redis is not None: + await app.state.debug_redis.aclose() + + +app = FastAPI(title="Gapido Auth Demo", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +def get_auth_client(request: Request) -> AuthClient: + return cast(AuthClient, request.app.state.auth_client) + + +def get_app_settings(request: Request) -> Settings: + return cast(Settings, request.app.state.settings) + + +def get_debug_redis(request: Request) -> Redis | None: + return cast(Redis | None, request.app.state.debug_redis) + + +@app.get("/") +async def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.post("/api/auth/request-otp") +async def request_otp( + body: RequestOtpBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast( + dict[str, object], + await _call_grpc(lambda: client.request_otp(body.mobile, body.purpose)), + ) + + +@app.post("/api/auth/verify-otp") +async def verify_otp( + body: VerifyOtpBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + response = await _call_grpc(lambda: client.verify_otp(body.mobile, body.code, body.purpose)) + return cast(dict[str, object], asdict(response)) + + +@app.post("/api/auth/refresh") +async def refresh_token( + body: RefreshBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + response = await _call_grpc(lambda: client.refresh_token(body.refresh_token)) + return cast(dict[str, object], asdict(response)) + + +@app.post("/api/auth/revoke") +async def revoke_refresh_token( + body: RevokeBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast( + dict[str, object], + await _call_grpc( + lambda: client.revoke_refresh_token(body.access_token, body.refresh_token) + ), + ) + + +@app.post("/api/demo/public") +async def public_demo(client: Annotated[AuthClient, Depends(get_auth_client)]) -> dict[str, object]: + return cast(dict[str, object], await _call_grpc(client.public_ping)) + + +@app.post("/api/demo/user") +async def user_demo( + body: TokenBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast(dict[str, object], await _call_grpc(lambda: client.user_only(body.access_token))) + + +@app.post("/api/demo/admin") +async def admin_demo( + body: TokenBody, + client: Annotated[AuthClient, Depends(get_auth_client)], +) -> dict[str, object]: + return cast(dict[str, object], await _call_grpc(lambda: client.admin_only(body.access_token))) + + +@app.get("/api/debug/otp") +async def debug_otp( + mobile: Annotated[str, Query(min_length=10, max_length=16)], + settings: Annotated[Settings, Depends(get_app_settings)], + redis: Annotated[Redis | None, Depends(get_debug_redis)], +) -> dict[str, str | None]: + 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)) + return {"mobile": mobile, "code": code} + + +async def _call_grpc(call: Callable[[], Awaitable[T]]) -> T: + try: + return await call() + except grpc.aio.AioRpcError as exc: + status_code = _grpc_to_http_status(exc.code()) + detail = exc.details() or exc.code().name + raise HTTPException(status_code=status_code, detail=detail) from exc + + +def _grpc_to_http_status(code: grpc.StatusCode) -> int: + match code: + case grpc.StatusCode.INVALID_ARGUMENT: + return 400 + case grpc.StatusCode.UNAUTHENTICATED: + return 401 + case grpc.StatusCode.PERMISSION_DENIED: + return 403 + case grpc.StatusCode.RESOURCE_EXHAUSTED: + return 429 + case grpc.StatusCode.UNAVAILABLE: + return 503 + case _: + return 502 diff --git a/src/gapido_auth/demo/grpc_client.py b/src/gapido_auth/demo/grpc_client.py new file mode 100644 index 0000000..b6af1b8 --- /dev/null +++ b/src/gapido_auth/demo/grpc_client.py @@ -0,0 +1,88 @@ +# mypy: disable-error-code="attr-defined,no-untyped-call" +from dataclasses import dataclass +from typing import Any + +import grpc + +from gapido_auth.generated import auth_pb2, auth_pb2_grpc + + +@dataclass(frozen=True, slots=True) +class DemoTokenResponse: + access_token: str + refresh_token: str + token_type: str + expires_in: int + role: str + + +class AuthGrpcClient: + def __init__(self, target: str) -> None: + self._channel = grpc.aio.insecure_channel(target) + self._stub = auth_pb2_grpc.AuthServiceStub(self._channel) + + async def close(self) -> None: + await self._channel.close() + + async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]: + 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: + 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: + 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]: + response = await self._stub.RevokeRefreshToken( + auth_pb2.RevokeRefreshTokenRequest(refresh_token=refresh_token), + metadata=_auth_metadata(access_token), + ) + return {"revoked": bool(response.revoked)} + + async def public_ping(self) -> dict[str, str]: + response = await self._stub.PublicPing(auth_pb2.PingRequest()) + return {"message": str(response.message)} + + async def user_only(self, access_token: str) -> dict[str, str]: + 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]: + response = await self._stub.AdminOnly( + auth_pb2.ProtectedRequest(), metadata=_auth_metadata(access_token) + ) + return _protected_response(response) + + +def _auth_metadata(access_token: str) -> tuple[tuple[str, str], ...]: + return (("authorization", f"Bearer {access_token}"),) + + +def _token_response(response: Any) -> DemoTokenResponse: + return DemoTokenResponse( + access_token=str(response.access_token), + refresh_token=str(response.refresh_token), + token_type=str(response.token_type), + expires_in=int(response.expires_in), + role=str(response.role), + ) + + +def _protected_response(response: Any) -> dict[str, str]: + return { + "user_id": str(response.user_id), + "role": str(response.role), + "message": str(response.message), + } diff --git a/src/gapido_auth/demo/static/app.js b/src/gapido_auth/demo/static/app.js new file mode 100644 index 0000000..943328d --- /dev/null +++ b/src/gapido_auth/demo/static/app.js @@ -0,0 +1,139 @@ +const state = { + accessToken: sessionStorage.getItem("accessToken") || "", + refreshToken: sessionStorage.getItem("refreshToken") || "", + role: sessionStorage.getItem("role") || "", + expiresIn: sessionStorage.getItem("expiresIn") || "", +}; + +const $ = (id) => document.getElementById(id); + +function render() { + $("auth-status").textContent = state.accessToken ? "Signed in" : "Signed out"; + $("role").textContent = state.role || "-"; + $("ttl").textContent = state.expiresIn ? `${state.expiresIn}s` : "-"; + $("token-preview").textContent = state.accessToken ? `${state.accessToken.slice(0, 24)}...` : "-"; +} + +function saveTokens(tokens) { + state.accessToken = tokens.access_token; + state.refreshToken = tokens.refresh_token; + state.role = tokens.role; + state.expiresIn = String(tokens.expires_in); + sessionStorage.setItem("accessToken", state.accessToken); + sessionStorage.setItem("refreshToken", state.refreshToken); + sessionStorage.setItem("role", state.role); + sessionStorage.setItem("expiresIn", state.expiresIn); + render(); +} + +function clearTokens() { + state.accessToken = ""; + state.refreshToken = ""; + state.role = ""; + state.expiresIn = ""; + sessionStorage.clear(); + render(); +} + +function show(data) { + $("result").textContent = JSON.stringify(data, null, 2); +} + +async function api(path, body = undefined, method = "POST") { + const response = await fetch(path, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await response.json(); + if (!response.ok) { + throw data; + } + return data; +} + +$("request-otp").addEventListener("click", async () => { + try { + const data = await api("/api/auth/request-otp", { mobile: $("mobile").value, purpose: "login" }); + show(data); + } catch (error) { + show(error); + } +}); + +$("debug-otp").addEventListener("click", async () => { + try { + const mobile = encodeURIComponent($("mobile").value); + const data = await api(`/api/debug/otp?mobile=${mobile}`, undefined, "GET"); + if (data.code) $("otp").value = data.code; + show(data); + } catch (error) { + show(error); + } +}); + +$("login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const tokens = await api("/api/auth/verify-otp", { + mobile: $("mobile").value, + code: $("otp").value, + purpose: "login", + }); + saveTokens(tokens); + show({ signed_in: true, role: tokens.role }); + } catch (error) { + show(error); + } +}); + +$("refresh").addEventListener("click", async () => { + try { + const tokens = await api("/api/auth/refresh", { refresh_token: state.refreshToken }); + saveTokens(tokens); + show({ refreshed: true, role: tokens.role }); + } catch (error) { + show(error); + } +}); + +$("logout").addEventListener("click", async () => { + try { + const data = await api("/api/auth/revoke", { + access_token: state.accessToken, + refresh_token: state.refreshToken, + }); + clearTokens(); + show(data); + } catch (error) { + clearTokens(); + show(error); + } +}); + +$("public").addEventListener("click", async () => { + try { + show(await api("/api/demo/public")); + } catch (error) { + show(error); + } +}); + +$("user").addEventListener("click", async () => { + try { + show(await api("/api/demo/user", { access_token: state.accessToken })); + } catch (error) { + show(error); + } +}); + +$("admin").addEventListener("click", async () => { + try { + show(await api("/api/demo/admin", { access_token: state.accessToken })); + } catch (error) { + show(error); + } +}); + +render(); + diff --git a/src/gapido_auth/demo/static/index.html b/src/gapido_auth/demo/static/index.html new file mode 100644 index 0000000..380f8f7 --- /dev/null +++ b/src/gapido_auth/demo/static/index.html @@ -0,0 +1,65 @@ + + +
+ + +Microservice Auth Demo
+{ "ready": true }
+