auth-fixes (#3)

## Summary

Resolve `{ENV_VAR}` placeholders in the OpenAPI spec before serving it. Replace the monolithic `x-introspect-url` extension with composable `x-server-url` + individual `x-*-path` fields so auth endpoints are configurable per-environment without hardcoding.

## Changes

- **`openapi_first/app.py`** — add `_resolve_spec_env_vars()` that replaces `{ENV_VAR}` patterns (e.g. `{AUTH_SERVER}`) with the corresponding OS environment variable before returning the spec JSON. Called in `__init__` after spec load.
- **`openapi_first/security.py`** — build the introspection URL dynamically from `x-server-url` + `x-introspect-path` extensions on the `bearerAuth` security scheme, instead of reading a single `x-introspect-url`.

## Migration

Existing specs using `x-introspect-url: "https://auth.example.com/introspect"` must switch to the new extension format:

```yaml
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      x-server-url: "{AUTH_SERVER}"
      x-login-path: "/login"
      x-register-path: "/register"
      x-logout-path: "/logout"
      x-me-path: "/me"
      x-introspect-path: "/introspect"

Reviewed-on: #3
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
2026-07-19 14:47:02 +00:00
committed by aetos
parent 7d075b3904
commit b3f3068f8d
5 changed files with 213 additions and 12 deletions

View File

@@ -102,6 +102,7 @@ from . import client
from . import errors
from . import codegen
from . import codegen_routes
from . import security
__all__ = [
"app",
@@ -111,4 +112,5 @@ __all__ = [
"errors",
"codegen",
"codegen_routes",
"security",
]

View File

@@ -32,10 +32,34 @@ Notes:
- Alter FastAPI dependency injection semantics.
"""
import os
import re
from fastapi import FastAPI
from .loader import load_openapi
from .binder import bind_routes
from .loader import load_openapi
from .security import make_security_dependencies, parse_security_schemes
_env_pattern = re.compile(r"\{(\w+)\}")
def _resolve_env(value: str) -> str:
"""Replace {ENV_VAR} placeholders with values from os.environ."""
def _replace(m: re.Match) -> str:
return os.environ.get(m.group(1), m.group(0))
return _env_pattern.sub(_replace, value)
def _resolve_spec_env_vars(obj):
"""Recursively resolve {ENV_VAR} in all string values of the spec."""
if isinstance(obj, str):
return _resolve_env(obj)
if isinstance(obj, dict):
return {k: _resolve_spec_env_vars(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_resolve_spec_env_vars(v) for v in obj]
return obj
class OpenAPIFirstApp(FastAPI):
@@ -48,15 +72,19 @@ class OpenAPIFirstApp(FastAPI):
- `OpenAPIFirstApp` subclasses `FastAPI` and replaces manual route
registration with OpenAPI-driven binding.
- All routes are derived from the provided OpenAPI specification,
and each `operationId` is mapped to a Python function in the
and each ``operationId`` is mapped to a Python function in the
supplied routes module.
- Auth dependencies are auto-injected from the spec's
``securitySchemes`` and per-operation ``security`` fields.
**Guarantees:**
- No route can exist without an OpenAPI declaration.
- No OpenAPI operation can exist without a handler.
- Swagger UI and `/openapi.json` always reflect the provided spec.
- Swagger UI and ``/openapi.json`` always reflect the provided spec.
- Handler functions remain framework-agnostic and testable.
- Auth enforcement is driven entirely by the spec — no manual
middleware or decorators required.
Example:
```python
@@ -87,28 +115,33 @@ class OpenAPIFirstApp(FastAPI):
specification is treated as the authoritative API contract.
routes_module (module):
Python module containing handler functions whose names correspond
exactly to OpenAPI `operationId` values.
exactly to OpenAPI ``operationId`` values.
**fastapi_kwargs (Any):
Additional keyword arguments passed directly to
`fastapi.FastAPI` (e.g., title, version, middleware, lifespan
``fastapi.FastAPI`` (e.g., title, version, middleware, lifespan
handlers).
Raises:
OpenAPIFirstError:
If the OpenAPI specification is invalid, or if any declared
`operationId` does not have a corresponding handler function.
``operationId`` does not have a corresponding handler function.
"""
# Initialize FastAPI normally
super().__init__(**fastapi_kwargs)
# Load and validate OpenAPI specification
self._openapi_spec = load_openapi(openapi_path)
self._openapi_spec = _resolve_spec_env_vars(load_openapi(openapi_path))
# Bind routes strictly from OpenAPI spec
# Parse security schemes and build per-route dependencies
security_schemes = parse_security_schemes(self._openapi_spec)
security_deps = make_security_dependencies(self._openapi_spec, security_schemes)
# Bind routes strictly from OpenAPI spec (with security deps)
bind_routes(
app=self,
spec=self._openapi_spec,
routes_module=routes_module,
security_deps=security_deps,
)
# Override FastAPI's OpenAPI generation

View File

@@ -32,12 +32,19 @@ Notes:
- Interpret OpenAPI semantics beyond routing metadata.
"""
from typing import Any
from fastapi.routing import APIRoute
from .errors import MissingOperationHandler
def bind_routes(app, spec: dict, routes_module) -> None:
def bind_routes(
app,
spec: dict,
routes_module,
security_deps: dict[str, list[Any]] | None = None,
) -> None:
"""
Bind OpenAPI operations to FastAPI routes.
@@ -49,18 +56,24 @@ def bind_routes(app, spec: dict, routes_module) -> None:
routes_module (module):
Python module containing handler functions. Each handler's name MUST
exactly match an OpenAPI `operationId`.
security_deps (dict | None):
Optional mapping of ``METHOD:/path`` → ``list[Depends(...)]``
generated from the spec's ``securitySchemes`` and per-operation
``security`` fields.
Raises:
MissingOperationHandler:
If an `operationId` is missing from the spec or if no corresponding
If an ``operationId`` is missing from the spec or if no corresponding
handler function exists in the routes module.
Notes:
**Responsibilities:**
- Iterates through the OpenAPI specification paths and methods.
- Resolves each `operationId` to a handler function, and registers
a corresponding `APIRoute` on the FastAPI application.
- Resolves each ``operationId`` to a handler function, and registers
a corresponding ``APIRoute`` on the FastAPI application.
- Injects FastAPI ``Depends()`` for each security requirement found
on the operation or inherited from the top-level ``security`` field.
**Guarantees:**
@@ -70,6 +83,7 @@ def bind_routes(app, spec: dict, routes_module) -> None:
"""
paths = spec.get("paths", {})
security_deps = security_deps or {}
for path, methods in paths.items():
for http_method, operation in methods.items():
@@ -90,10 +104,14 @@ def bind_routes(app, spec: dict, routes_module) -> None:
operation_id=operation_id,
)
key = f"{http_method.upper()}:{path}"
deps = security_deps.get(key, [])
route = APIRoute(
path=path,
endpoint=endpoint,
methods=[http_method.upper()],
dependencies=deps,
name=operation_id,
)

145
openapi_first/security.py Normal file
View File

@@ -0,0 +1,145 @@
"""
OpenAPI security scheme parsing and auto-generated auth dependencies.
Reads `securitySchemes` and per-operation `security` from an OpenAPI spec,
resolves `{ENV_VAR}` placeholders in `x-` extension fields, and generates
FastAPI dependencies for token validation (e.g., Bearer JWT introspection).
"""
import os
import re
from typing import Any, Callable
import httpx
from fastapi import Depends, Request, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
_env_pattern = re.compile(r"\{(\w+)\}")
def _resolve_env(value: str) -> str:
"""Replace {ENV_VAR} placeholders with values from os.environ."""
def _replace(m: re.Match) -> str:
return os.environ.get(m.group(1), "")
return _env_pattern.sub(_replace, value)
def _resolve_scheme(scheme: dict) -> dict:
"""Recursively resolve env vars in all string-valued fields of a scheme."""
resolved = {}
for key, value in scheme.items():
if isinstance(value, str):
resolved[key] = _resolve_env(value)
elif isinstance(value, dict):
resolved[key] = _resolve_scheme(value)
else:
resolved[key] = value
return resolved
def parse_security_schemes(spec: dict) -> dict[str, dict]:
"""Extract and resolve environment variables in security schemes."""
raw = spec.get("components", {}).get("securitySchemes", {})
return {name: _resolve_scheme(scheme) for name, scheme in raw.items()}
def _make_bearer_dependency(introspect_url: str | None) -> Callable:
"""
Create a FastAPI dependency that validates a Bearer JWT.
If *introspect_url* is provided the dependency calls that endpoint
with ``{"token": "<token>"}`` and expects ``{"active": true, "user": …}``
back. The resolved user dict is stored on ``request.state.user``.
Without an introspection URL the dependency only extracts the raw token
and stores it on ``request.state.token`` — no remote validation.
"""
bearer = HTTPBearer(auto_error=False)
async def _bearer_dep(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
) -> None:
if credentials is None:
raise HTTPException(status_code=401, detail="Not authenticated")
token = credentials.credentials
if introspect_url:
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.post(
introspect_url,
json={"token": token},
)
except httpx.RequestError:
raise HTTPException(
status_code=503,
detail="Authentication service unavailable",
)
if resp.status_code != 200:
raise HTTPException(status_code=401, detail="Invalid or expired token")
body = resp.json()
if not body.get("active", False):
raise HTTPException(status_code=401, detail="Invalid or expired token")
request.state.user = body.get("user")
else:
request.state.token = token
return _bearer_dep
def _build_dependency(scheme_name: str, scheme: dict) -> Callable | None:
"""Return a FastAPI dependency callable for *scheme*, or ``None``."""
scheme_type = scheme.get("type")
if scheme_type == "http" and scheme.get("scheme") == "bearer":
server_url = scheme.get("x-server-url", "")
introspect_path = scheme.get("x-introspect-path", "/introspect")
introspect_url = server_url + introspect_path if server_url else None
return _make_bearer_dependency(introspect_url)
return None
def make_security_dependencies(
spec: dict,
security_schemes: dict[str, dict],
) -> dict[str, list[Any]]:
"""
Build a mapping of ``METHOD:/path`` → list of ``Depends(...)``.
The effective security for each operation is resolved by:
1. Using the operation-level ``security`` field if present.
2. Falling back to the top-level ``security`` field.
3. An empty list means *no auth required* for that operation.
"""
global_security = spec.get("security", [])
paths = spec.get("paths", {})
result: dict[str, list[Any]] = {}
for path, methods in paths.items():
for http_method, operation in methods.items():
if http_method.startswith("x-"):
continue
op_security = operation.get("security", global_security)
depends_list: list[Any] = []
for sec_req in op_security:
for scheme_name in sec_req:
scheme = security_schemes.get(scheme_name)
if scheme:
dep = _build_dependency(scheme_name, scheme)
if dep:
depends_list.append(Depends(dep))
key = f"{http_method.upper()}:{path}"
result[key] = depends_list
return result

View File

@@ -55,6 +55,9 @@ dependencies = [
# Code generation
"datamodel-code-generator>=0.25.0",
# HTTP client for service-to-service (token introspection)
"httpx>=0.27.0",
]
[project.scripts]