Middleware¶
The SDK provides two middleware classes. Use AuthzMiddleware in authz mode (dual-token) or JWTAuthMiddleware in proxy mode (single JWT). If you use the Duar class, protect() adds the correct one automatically.
AuthzMiddleware (AuthZ Mode)¶
Validates both an IdP token and a Duar authorization token on each request. Checks that the sub claims match across tokens (binding verification).
Headers¶
| Header | Content |
|---|---|
Authorization |
Bearer <idp_token> |
X-Authz-Token |
<duar_authz_token> |
X-Workspace-Id |
<workspace uuid> — only with auto_resolve=True, instead of X-Authz-Token |
Setup¶
from duar_auth.authz_middleware import AuthzMiddleware
app.add_middleware(
AuthzMiddleware,
service_name="my-service",
idp_audience="123-abc.apps.googleusercontent.com",
idp_issuer="https://accounts.google.com",
idp_jwks_url="https://www.googleapis.com/oauth2/v3/certs",
duar_public_key=duar_pem,
exclude_paths=["/health", "/docs", "/openapi.json"],
)
Or pass a Duar instance (keys are read lazily -- safe to call before lifespan). In that case service_name, idp_audience, and idp_issuer are picked up from the Duar instance:
Constructor Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
service_name |
str |
required | Binds this middleware to a single service. The authz token's svc claim MUST equal this. Prevents a token minted for another service from being replayed here. |
idp_audience |
str \| list[str] |
required | The IdP token's expected aud claim — typically your OAuth client_id. Without this check, any valid token from any OAuth client of the same IdP authenticates. |
idp_issuer |
str \| None |
None |
Expected IdP iss claim. Strongly recommended. |
idp_public_key |
str \| None |
None |
PEM key for IdP token validation |
idp_jwks_url |
str \| None |
None |
JWKS endpoint for IdP tokens (handles key rotation) |
duar_public_key |
str \| None |
None |
PEM key for authz token validation |
duar_instance |
Duar \| None |
None |
Duar instance (reads keys lazily) |
idp_algorithm |
str |
"RS256" |
IdP token signing algorithm |
duar_algorithm |
str |
"RS256" |
Authz token signing algorithm |
duar_audience |
str |
"duar:authz" |
Expected aud claim in authz token |
idp_provider |
str \| None |
None |
Provider Duar validates IdP tokens as: "google" or "entra_id". Required with auto_resolve. |
auto_resolve |
bool |
False |
Mint the authz token server-side when a request carries only the IdP token plus X-Workspace-Id. Requires duar_instance and idp_provider. |
exclude_paths |
list[str] \| None |
["/health", "/docs", "/openapi.json"] |
Paths that bypass authentication |
Either duar_public_key or duar_instance is required. For IdP validation, the middleware uses idp_jwks_url or idp_public_key (from the params or from the Duar instance).
Request State¶
After successful validation, the middleware sets:
| Attribute | Type | Description |
|---|---|---|
request.state.user |
AuthenticatedUser |
User built from authz token claims + IdP email/name |
request.state.token |
str |
The Duar authz token |
request.state.idp_token |
str |
The original IdP token |
Validation Steps¶
- Extract IdP token from
Authorization: Bearer ... - Extract authz token from
X-Authz-Token - Validate IdP token: signature, expiry,
aud == idp_audience(+iss == idp_issuerif configured) via JWKS or static key - Validate authz token: signature, expiry,
aud == "duar:authz" - Verify binding: IdP
submust equal authzidp_sub(both must be non-empty) - Verify service binding: authz
svcmust equalservice_name - Build
AuthenticatedUserand set onrequest.state
OPTIONS requests are passed through without validation.
Calling the API from scripts (auto-resolve)¶
Browsers hold both tokens; a script, Postman, or Swagger usually has only an IdP token. With auto_resolve=True the middleware mints the authz token itself when a request sends the IdP token plus the target workspace:
curl https://api.example.com/reports \
-H "Authorization: Bearer $ID_TOKEN" \
-H "X-Workspace-Id: 5e60ba90-4b3e-4b1a-9dcb-9d76b1a1e3a1"
How it works: the IdP token is verified locally first (bad tokens never reach Duar), then the middleware calls POST /authz/resolve with the service key, caches the token per (idp_sub, workspace) for 80% of its TTL, and de-duplicates concurrent first requests into one mint. Everything after that — idp_sub binding, svc binding, request.state — is the normal dual-token path. X-Authz-Token always wins when both headers are present. Missing both → 401 telling the caller which header to send; a non-UUID X-Workspace-Id → 400.
Not a new trust boundary: the app could already mint for any valid IdP token through its mint endpoint; this only removes the second round trip. Membership, organisation, and active-user checks still run on every mint, and the IdP token's aud is still pinned to your client id. Opaque (GitHub) tokens are not supported — the middleware requires a JWT IdP token.
JWTAuthMiddleware (Proxy Mode)¶
Validates a single Duar-issued JWT. Used when Duar handles the full OAuth flow.
Setup¶
from duar_auth.middleware import JWTAuthMiddleware
# Recommended: fetch key from JWKS automatically
app.add_middleware(
JWTAuthMiddleware,
base_url="https://duar.example.com",
)
# Alternative: static PEM key
app.add_middleware(
JWTAuthMiddleware,
public_key=Path("keys/public.pem").read_text(),
)
Constructor Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
base_url |
str \| None |
None |
Duar URL. JWKS endpoint derived as {base_url}/.well-known/jwks.json |
public_key |
str \| None |
None |
RSA PEM key for air-gapped deployments |
jwks_url |
str \| None |
None |
Explicit JWKS URL (non-standard paths only) |
algorithm |
str |
"RS256" |
JWT signing algorithm |
audience |
str |
"duar:access" |
Expected aud claim |
exclude_paths |
list[str] \| None |
["/health", "/docs", "/openapi.json"] |
Paths that bypass authentication |
allowed_workspaces |
set[str] \| None |
None |
Workspace IDs permitted. None allows all |
One of base_url, jwks_url, or public_key is required. The JWKS key is fetched lazily on first request and cached.
Request State¶
| Attribute | Type | Description |
|---|---|---|
request.state.user |
AuthenticatedUser |
User built from JWT claims |
request.state.token |
str |
The raw JWT string |
Excluded Paths¶
Both middleware classes skip authentication for excluded paths. Matching uses exact match or prefix with / boundary:
# Path "/health" matches: /health, /health/ready
# Path "/docs" matches: /docs, /docs/oauth2-redirect
# Path "/docs" does NOT match: /documents
Error Responses¶
Both middleware classes return JSON errors:
| Status | Detail | When |
|---|---|---|
| 401 | Missing IdP token / Missing or invalid Authorization header |
No Authorization: Bearer header |
| 401 | Missing authz token |
No X-Authz-Token header (authz mode only) |
| 401 | IdP token expired / Token has expired |
Token exp in the past |
| 401 | Invalid IdP token / Invalid token |
Bad signature, malformed, wrong audience |
| 401 | Token binding mismatch: idp_sub does not match |
IdP sub != authz idp_sub (authz mode) |
| 401 | Invalid token claims |
Missing required claims in payload |
| 403 | Authz token was issued for a different service |
authz svc claim != service_name (authz mode) |
| 403 | Workspace not permitted for this service |
Workspace not in allowed_workspaces |
| 500 | Authentication service unavailable |
JWKS fetch failed (proxy mode) |
| 401 | Missing authz token — send X-Authz-Token, or X-Workspace-Id to resolve one server-side |
auto_resolve=True and neither header sent |
| 400 | Invalid X-Workspace-Id |
X-Workspace-Id is not a UUID (auto-resolve) |
| 401 | IdP token rejected by Duar: <Duar's reason> |
Duar refused the IdP token at mint — audience not allowed for this app, unsupported idp_provider, … (auto-resolve) |
| 403 | Not authorized for this workspace |
Not a member / org not allowed / inactive / cross-provider email conflict (auto-resolve) |
| 429 | Authorization service rate limit |
Duar's /authz/resolve limit hit; Retry-After passed through (auto-resolve) |
| 503 | Authorization service rejected the service key |
Duar returned 401 to the mint — this app's service key is missing or revoked (auto-resolve) |
| 503 | Authorization service unavailable |
Duar unreachable, 5xx, or an unusable mint response (auto-resolve) |