245 lines
8.1 KiB
Python
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
|