diff --git a/app/api/routes/capture.py b/app/api/routes/capture.py index 2fd6061..ef549de 100644 --- a/app/api/routes/capture.py +++ b/app/api/routes/capture.py @@ -19,6 +19,7 @@ "/session", response_model=CaptureSessionResponse, status_code=status.HTTP_201_CREATED, + summary="Create capture session", responses={ 401: {"description": "Invalid or missing device token"}, }, @@ -53,6 +54,7 @@ async def create_session( "/trust", response_model=TrustResponse, status_code=status.HTTP_200_OK, + summary="Generate trust token", responses={ 400: {"description": "Invalid or expired nonce"}, }, diff --git a/app/api/routes/device.py b/app/api/routes/device.py index a70f0fd..b7d26e4 100644 --- a/app/api/routes/device.py +++ b/app/api/routes/device.py @@ -24,6 +24,7 @@ "", response_model=DeviceCreateResponse, status_code=status.HTTP_201_CREATED, + summary="Register a device", responses={ 401: {"description": "Attestation verification failed"}, 404: {"description": "Publisher not found"}, diff --git a/app/api/routes/health.py b/app/api/routes/health.py index 7f0de7c..090e5bd 100644 --- a/app/api/routes/health.py +++ b/app/api/routes/health.py @@ -1,11 +1,28 @@ from fastapi import APIRouter +from pydantic import BaseModel, Field from app.settings import settings router = APIRouter(tags=["health"]) -@router.get("/health") -async def health() -> dict[str, str]: - """Health check endpoint.""" - return {"status": "ok", "version": settings.app_version} +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = Field( + description="Service status. 'ok' when healthy.", examples=["ok"] + ) + version: str = Field( + description="Deployed application version.", examples=["v0.1.0"] + ) + + +@router.get("/health", response_model=HealthResponse, summary="Check API health") +async def health() -> HealthResponse: + """ + Health check endpoint. + + Returns the service status and deployed version. Use this endpoint to verify + the API is running and to check which version is deployed. + """ + return HealthResponse(status="ok", version=settings.app_version) diff --git a/app/api/routes/jwks.py b/app/api/routes/jwks.py index 5633223..fe3f336 100644 --- a/app/api/routes/jwks.py +++ b/app/api/routes/jwks.py @@ -3,10 +3,12 @@ from app.schemas.jwk import JWKSResponse from app.services.jwk import JWKService, get_jwk_service_singleton -router = APIRouter(tags=["jwks"]) +router = APIRouter(tags=["security"]) -@router.get("/.well-known/jwks.json", response_model=JWKSResponse) +@router.get( + "/.well-known/jwks.json", response_model=JWKSResponse, summary="Get public keys" +) def get_jwks( jwk_service: JWKService = Depends(get_jwk_service_singleton), ) -> JWKSResponse: diff --git a/app/api/routes/publisher.py b/app/api/routes/publisher.py index 22fdd9e..ec73dd4 100644 --- a/app/api/routes/publisher.py +++ b/app/api/routes/publisher.py @@ -45,6 +45,7 @@ def _publisher_to_response(publisher: Publisher) -> PublisherCreateResponse: "", response_model=PublisherCreateResponse, status_code=status.HTTP_201_CREATED, + summary="Create a publisher", responses={ 403: {"description": "Publisher API is disabled"}, 409: {"description": "Publisher already exists"}, @@ -77,6 +78,7 @@ async def create_publisher( @router.patch( "/{publisher_id}", response_model=PublisherCreateResponse, + summary="Update a publisher", responses={ 403: {"description": "Publisher API is disabled"}, 404: {"description": "Publisher not found"}, @@ -125,6 +127,7 @@ async def update_publisher( @router.get( "/{publisher_id}", response_model=PublisherCreateResponse, + summary="Get a publisher", responses={ 403: {"description": "Publisher API is disabled"}, 404: {"description": "Publisher not found"}, diff --git a/app/api/routes/validate.py b/app/api/routes/validate.py index b12e2ad..0cf393c 100644 --- a/app/api/routes/validate.py +++ b/app/api/routes/validate.py @@ -15,6 +15,7 @@ "", response_model=ValidationResponse, status_code=status.HTTP_200_OK, + summary="Validate signed media", responses={ 400: {"description": "Invalid sidecar format or validation error"}, }, diff --git a/app/main.py b/app/main.py index 27e972e..f897f3c 100644 --- a/app/main.py +++ b/app/main.py @@ -57,19 +57,48 @@ def render(self, content: Any) -> bytes: 1. **Register a device** - Get a device token for authentication 2. **Create a capture session** - Get a one-time nonce before capturing media 3. **Exchange nonce for trust token** - Get a signed JWT proving capture authenticity +4. **Validate media** - Verify authenticity of signed media with its sidecar + +## Authentication + +- Device endpoints require an `Authorization: Bearer ` header +- Publisher ID is passed via `X-Publisher-ID` header during device registration +- Attestation token (Firebase App Check) is passed via `X-Attestation-Token` header """, openapi_tags=[ + { + "name": "publishers", + "description": "Publisher registration and management. Publishers are apps that use SignedShot.", + }, { "name": "devices", - "description": "Device registration and management", + "description": "Device registration and management. Devices are user phones running a publisher's app.", }, { "name": "capture", - "description": "Capture sessions and trust token generation", + "description": "Capture sessions and trust token generation.", + }, + { + "name": "validate", + "description": "Media validation and verification.", + }, + { + "name": "security", + "description": "Security endpoints for JWT verification.", }, { "name": "health", - "description": "Health check endpoints", + "description": "Health check endpoints.", + }, + ], + servers=[ + { + "url": "https://api.signedshot.io", + "description": "Production API", + }, + { + "url": "https://dev-api.signedshot.io", + "description": "Development API (open for testing)", }, ], ) diff --git a/app/schemas/jwk.py b/app/schemas/jwk.py index d6093b4..32dddd2 100644 --- a/app/schemas/jwk.py +++ b/app/schemas/jwk.py @@ -1,19 +1,39 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class JWK(BaseModel): """JSON Web Key for EC key.""" - kty: str # Key type, always "EC" for elliptic curve - crv: str # Curve, "P-256" for ES256 - x: str # Base64url-encoded X coordinate - y: str # Base64url-encoded Y coordinate - use: str # Key usage, "sig" for signature - alg: str # Algorithm, "ES256" - kid: str # Key ID + kty: str = Field( + description="Key type. Always 'EC' for elliptic curve keys.", + examples=["EC"], + ) + crv: str = Field( + description="Cryptographic curve. 'P-256' for ES256 algorithm.", + examples=["P-256"], + ) + x: str = Field( + description="Base64url-encoded X coordinate of the elliptic curve point.", + ) + y: str = Field( + description="Base64url-encoded Y coordinate of the elliptic curve point.", + ) + use: str = Field( + description="Key usage. 'sig' indicates this key is used for signatures.", + examples=["sig"], + ) + alg: str = Field( + description="Algorithm. 'ES256' for ECDSA using P-256 and SHA-256.", + examples=["ES256"], + ) + kid: str = Field( + description="Key ID. Unique identifier for this key, used to match JWT headers.", + ) class JWKSResponse(BaseModel): - """JSON Web Key Set response.""" + """JSON Web Key Set response for JWT verification.""" - keys: list[JWK] + keys: list[JWK] = Field( + description="List of public keys available for JWT verification.", + )