Authentication¶
This guide covers QuoinAPI's OAuth 2.0 / 2.1 authentication system for service-to-service API access.
Overview¶
QuoinAPI uses Bearer token authentication based on the OAuth 2.0 Client Credentials grant. There are no user sessions, cookies, or passwords. Every API call is authenticated by validating a signed JWT issued by your authorization server.
The security core is provider-agnostic: it works with any OIDC-compliant server (Azure AD, Okta, Auth0, Keycloak) via standard JWKS discovery.
Concepts¶
ServicePrincipal¶
ServicePrincipal is the resolved identity of an authenticated calling
service — not a human user. It is built from validated JWT claims and
injected into every protected route by require_roles().
class ServicePrincipal(BaseModel):
subject: str # JWT `sub` — stable, unique service identifier
roles: list[str] # Normalized app roles from the token
claims: dict[str, Any] # Full decoded JWT payload (for advanced use)
subject maps to the OAuth 2.0 sub claim, which is stable and
provider-agnostic across Azure AD, Auth0, Okta, and Keycloak:
{
"sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"roles": ["users.read"],
"iss": "https://login.microsoftonline.com/{tenant}/v2.0",
"aud": "api://your-app-client-id",
"exp": 1713484800
}
Use caller.subject in structured logs for audit trails:
App Roles (Domain Scoped)¶
Authorization is enforced via app roles embedded in the token.
QuoinAPI enforces Domain-Scoped Permissions rather than global read/write
permissions. Scopes should target specific resource bounded contexts formatted as [domain].[action]
to adhere to the Principle of Least Privilege.
| Role | Description | Protects |
|---|---|---|
users.read |
Read access to a domain | GET /api/v1/users/ |
users.write |
Mutation access to a domain | POST /api/v1/users/ |
api.superuser |
Global Bypass | Local testing and master scripts |
Routes explicitly declare which role they require via require_roles(...). There is no hidden baseline
role — every route is perfectly self-documenting.
Token Validation¶
Every request to a protected endpoint runs the following checks natively:
| Check | Source |
|---|---|
| Signature | JWKS from your OAuth server (cached with auto-rotation) |
| Expiry | exp claim |
| Audience | aud == QUOIN_OAUTH_AUDIENCE |
| Issuer | iss == QUOIN_OAUTH_ISSUER |
| Role | roles contains the specifically requested [domain].[action] |
Flow¶
sequenceDiagram
participant CS as Calling Service
participant OS as OAuth Server
participant QA as QuoinAPI
CS->>OS: client_credentials grant
OS-->>CS: JWT (sub, roles)
CS->>QA: Bearer <JWT>
QA->>OS: GET /jwks (cached)
OS-->>QA: public keys
Note over QA: validate sig / exp / aud<br/>check roles claim
QA-->>CS: 200 OK
Configuration¶
Add the following to your .env file:
# OAuth 2.0 — required for authentication
QUOIN_OAUTH_JWKS_URI=https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys
QUOIN_OAUTH_ISSUER=https://login.microsoftonline.com/{tenant}/v2.0
QUOIN_OAUTH_AUDIENCE=api://{your-app-client-id}
# Claim key — defaults work for Azure AD; adjust for other providers
QUOIN_OAUTH_ROLES_CLAIM=roles
Note
If your provider uses scopes instead of roles (e.g. Okta, Auth0
M2M), set QUOIN_OAUTH_ROLES_CLAIM=scope. The validator handles
both array (["users.read"]) and space-separated string
("users.read users.write") formats automatically.
Tip
The local mock-oauth2-server places roles in the aud claim, so
the development .env uses QUOIN_OAUTH_ROLES_CLAIM=aud.
Production IdPs (Azure AD, Auth0, Keycloak) use roles — the
default value.
Protecting Routes¶
Routes declare their own required roles explicitly using require_roles().
There is no implicit baseline — every route self-documents its access
requirement.
General Usage¶
from typing import Annotated
from fastapi import APIRouter, Depends
from app.core.security import ServicePrincipal, require_roles
router = APIRouter()
# Read — any caller with users.read OR api.superuser
@router.get("/")
async def list_users(
caller: Annotated[ServicePrincipal, Depends(require_roles("users.read"))]
): ...
# Write — any caller with users.write OR api.superuser
@router.post("/")
async def create_user(
caller: Annotated[ServicePrincipal, Depends(require_roles("users.write"))]
): ...
Dependency Graph¶
HTTPBearer()
└── get_token_claims() # Validates JWT, returns raw claims
└── get_current_caller() # Parses ServicePrincipal (no role check)
└── require_roles("users.read") # Domain checks
OAuth 2.1 Compatibility¶
QuoinAPI is compatible with both OAuth 2.0 and OAuth 2.1 for service-to-service calls. The Client Credentials grant is unchanged between the two specifications.
The key differences in OAuth 2.1 (implicit grant removal, PKCE requirement, refresh token rotation) apply to authorization servers — not resource servers like QuoinAPI. Your token validation code does not change.
Local Testing & Tokens¶
For testing and rapid development locally, QuoinAPI leverages two frameworks flawlessly.
Layer 1 — The mock-oauth2-server stack¶
The integration testing layer. mock-oauth2-server runs as a Docker service
alongside the database, issuing real RS256 JWTs from a real JWKS endpoint.
The script scripts/gen_token.py (wired simply as just token) allows you to generate completely signed and valid tokens bypassing real SSO networks instantly.
Testing Everything Instantly (The Bypass Token):
Because QuoinAPI supports the api.superuser bypass flag natively in the require_roles validator, you can test every single endpoint seamlessly simply by requesting one token:
Testing Explicit Constraints:
If you want to ensure your users.read role is getting blocked on the /delete endpoints appropriately:
# 1. Get a standard token strictly limited to `users.read`
TOKEN=$(just token roles="users.read")
# 2. Call a protected Read endpoint (e.g. fetching users) -> 200 OK
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/users/
# 3. Attempt to mutate (which requires `users.write`) -> 403 Forbidden
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"bad@caller.com", "full_name": "Eve"}' \
http://localhost:8000/api/v1/users/
Layer 2 — dependency_overrides natively in tests¶
The unit/fast testing layer used by the automated test suite. No containers,
no tokens, instant evaluating. Pytest scripts inject a pre-built ServicePrincipal
directly via standard FastAPI dependency injection mocks:
# tests/conftest.py — shared fixtures (already configured)
@pytest.fixture
def caller_read() -> ServicePrincipal:
return ServicePrincipal(
subject="test-service-read",
roles=["users.read"],
claims={},
)
Use in tests:
async def test_get_resource(read_client: AsyncClient) -> None:
response = await read_client.get("/api/v1/users/")
assert response.status_code == 200
Error Responses¶
| Status | When | Response |
|---|---|---|
401 Unauthorized |
No token, expired token, invalid signature | {"detail": "Unauthorized"} |
403 Forbidden |
Valid token, but missing required role | {"detail": "Forbidden"} |
The 401 response includes a WWW-Authenticate: Bearer header per RFC 6750.