Files
gapido-grpc-auth/src/gapido_demo/app.py

245 lines
8.1 KiB
Python

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.infrastructure.debug_sms_client import debug_sms_key
from gapido_demo.grpc_client import AuthGrpcClient, DemoTokenResponse
STATIC_DIR = Path(__file__).resolve().parent / "static"
T = TypeVar("T")
class AuthClient(Protocol):
"""Minimal client contract used by FastAPI routes and tests."""
async def request_otp(self, mobile: str, purpose: str) -> dict[str, bool]:
"""Ask the auth service to create and dispatch an OTP."""
...
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]:
"""Revoke a refresh session using bearer-token metadata."""
...
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]:
"""Call the user-protected demo RPC with an access token."""
...
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)
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:
"""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"}
@app.post("/api/auth/request-otp")
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)),
)
@app.post("/api/auth/verify-otp")
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))
@app.post("/api/auth/refresh")
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))
@app.post("/api/auth/revoke")
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(
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]:
"""Call the public gRPC endpoint to prove unauthenticated access."""
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]:
"""Call the user-protected gRPC endpoint with bearer metadata."""
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]:
"""Call the admin-protected gRPC endpoint with bearer metadata."""
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]:
"""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))
return {"mobile": mobile, "code": code}
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:
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:
"""Map auth-service gRPC status codes to browser-friendly HTTP statuses."""
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