diff --git a/oid4vc/auth_server/.gitignore b/oid4vc/auth_server/.gitignore
index a74bd2f5e..a74f83a09 100644
--- a/oid4vc/auth_server/.gitignore
+++ b/oid4vc/auth_server/.gitignore
@@ -203,6 +203,9 @@ cython_debug/
.env.*
!.env.*.example
+# Local demo/dev key material
+resources/.demo_keys.json
+
# Local test/analysis artifacts
.test-reports/
.VSCodeCounter/
diff --git a/oid4vc/auth_server/admin/config.py b/oid4vc/auth_server/admin/config.py
index 36a64eec2..8ed1851b7 100644
--- a/oid4vc/auth_server/admin/config.py
+++ b/oid4vc/auth_server/admin/config.py
@@ -1,44 +1,52 @@
-"""Application configuration."""
+"""Admin settings."""
from pydantic_settings import BaseSettings, SettingsConfigDict
+from sqlalchemy.engine import URL
class Settings(BaseSettings):
- """Application configuration."""
+ """Admin env vars (ADMIN_ prefix)."""
model_config = SettingsConfigDict(env_prefix="ADMIN_", extra="ignore")
+ # App metadata
APP_ROOT_PATH: str = ""
APP_TITLE: str = "OAuth 2.0 Authorization Server Admin API"
APP_VERSION: str = "0.1.0"
OPENAPI_URL: str = ""
- OAUTH_ISSUER: str = ""
- OAUTH_CLIENT_ID: str = ""
- OAUTH_JWKS_URL: str = ""
- MANAGE_AUTH_TOKEN: str = "manage_auth_token"
+ # Bearer tokens
+ MANAGE_AUTH_TOKEN: str = ""
+ INTERNAL_AUTH_TOKEN: str = ""
+ # Database
DB_DRIVER_ASYNC: str = "postgresql+asyncpg"
DB_DRIVER_SYNC: str = "postgresql+psycopg"
DB_HOST: str = "localhost"
DB_PORT: int = 5432
-
DB_NAME: str = "auth_server_admin"
DB_SCHEMA: str = "admin"
DB_USER: str = "postgres"
DB_PASSWORD: str = "postgres"
+ # Sized per replica: total connections = replicas x (size + overflow)
+ DB_POOL_SIZE: int = 5
+ DB_MAX_OVERFLOW: int = 10
+ DB_POOL_RECYCLE: int = 1800
+ # Tenant database
TENANT_DB_NAME: str = "auth_server_tenant"
TENANT_DB_SCHEMA: str = "auth"
- INTERNAL_AUTH_TOKEN: str = "internal_auth_token"
+ # Client settings
+ MIN_CLIENT_SECRET_LENGTH: int = 32
+ # Key encryption
KEY_VERIFY_GRACE_TTL: int = 604800 # seconds, JWKS grace after retirement
KEY_ENC_SECRETS: dict[str, str] = {}
KEY_ENC_VERSION: int = 1
- # CORS settings
- CORS_ALLOW_ORIGINS: list[str] = ["*"]
+ # CORS
+ CORS_ALLOW_ORIGINS: list[str] = []
CORS_ALLOW_METHODS: list[str] = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"]
CORS_ALLOW_HEADERS: list[str] = ["Authorization", "Content-Type"]
CORS_ALLOW_CREDENTIALS: bool = False
@@ -60,13 +68,15 @@ def DB_URL_SYNC(self) -> str:
def _get_db_conn_str(self, use_async: bool = True) -> str:
"""Return DB connection string by protocol."""
- DB_DRIVER = self.DB_DRIVER_ASYNC if use_async else self.DB_DRIVER_SYNC
- return (
- f"{DB_DRIVER}://"
- f"{self.DB_USER}:{self.DB_PASSWORD}"
- f"@{self.DB_HOST}:{self.DB_PORT}"
- f"/{self.DB_NAME}"
- )
+ driver = self.DB_DRIVER_ASYNC if use_async else self.DB_DRIVER_SYNC
+ return URL.create(
+ drivername=driver,
+ username=self.DB_USER,
+ password=self.DB_PASSWORD,
+ host=self.DB_HOST,
+ port=self.DB_PORT,
+ database=self.DB_NAME,
+ ).render_as_string(hide_password=False)
settings = Settings()
diff --git a/oid4vc/auth_server/admin/main.py b/oid4vc/auth_server/admin/main.py
index bc6bc1986..2a8ee5946 100644
--- a/oid4vc/auth_server/admin/main.py
+++ b/oid4vc/auth_server/admin/main.py
@@ -10,7 +10,7 @@
from admin.config import settings
from admin.deps import db_manager
-from admin.routers import internal, migrations, tenants
+from admin.routers import internal, migrations, tenants, wallet_providers
from core.observability.observability import (
RequestContextMiddleware,
setup_structlog_json,
@@ -25,7 +25,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Lifespan event handler."""
# Startup logic
setup_structlog_json()
- db_manager.init(settings.DB_URL)
+ db_manager.init(
+ settings.DB_URL,
+ pool_size=settings.DB_POOL_SIZE,
+ max_overflow=settings.DB_MAX_OVERFLOW,
+ pool_recycle=settings.DB_POOL_RECYCLE,
+ )
+ # Load wallet providers into JWKS cache
+ async with db_manager.session() as session:
+ from admin.services.internal_service import load_wallet_providers
+
+ await load_wallet_providers(session)
# Warn if encryption keys are not configured; secrets will be stored in plaintext
try:
active_ver = str(getattr(settings, "KEY_ENC_VERSION", 1))
@@ -64,6 +74,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.add_middleware(RequestContextMiddleware)
app.include_router(tenants.router, prefix="/admin", tags=["tenants"])
+app.include_router(wallet_providers.router, prefix="/admin", tags=["wallet-providers"])
app.include_router(migrations.router, prefix="/admin", tags=["migrations"])
app.include_router(internal.router, prefix="/internal", tags=["internal"])
@@ -76,11 +87,10 @@ async def health_check():
await session.execute(text("SELECT 1"))
return ORJSONResponse(content={"status": "ok"}, status_code=status.HTTP_200_OK)
except Exception as ex:
- error_message = f"database_unavailable: {ex}"
- logger.error(f"Health check failed: {error_message}")
+ logger.error("Health check failed: %s", ex)
return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- content={"status": "fail", "error": error_message},
+ content={"status": "fail", "error": "database_unavailable"},
)
diff --git a/oid4vc/auth_server/admin/models.py b/oid4vc/auth_server/admin/models.py
index 0234ae891..3aa071dec 100644
--- a/oid4vc/auth_server/admin/models.py
+++ b/oid4vc/auth_server/admin/models.py
@@ -71,3 +71,26 @@ class TenantKey(Base):
updated_at: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True), nullable=True, onupdate=func.now()
)
+
+
+class WalletProvider(Base):
+ """Trusted wallet provider for attestation verification (allow list)."""
+
+ __tablename__ = "wallet_provider"
+ __table_args__ = (
+ UniqueConstraint("iss", name="uq_wallet_provider_iss"),
+ {"schema": settings.DB_SCHEMA},
+ )
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+ iss: Mapped[str] = mapped_column(Text, nullable=False)
+ jwks: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+ jwks_uri: Mapped[str | None] = mapped_column(Text, nullable=True)
+ name: Mapped[str | None] = mapped_column(Text, nullable=True)
+ active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ created_at: Mapped[datetime] = mapped_column(
+ TIMESTAMP(timezone=True), nullable=False, default=func.now()
+ )
+ updated_at: Mapped[datetime | None] = mapped_column(
+ TIMESTAMP(timezone=True), nullable=True, onupdate=func.now()
+ )
diff --git a/oid4vc/auth_server/admin/repositories/tenant_key_repository.py b/oid4vc/auth_server/admin/repositories/tenant_key_repository.py
index f1852621c..c8ad50086 100644
--- a/oid4vc/auth_server/admin/repositories/tenant_key_repository.py
+++ b/oid4vc/auth_server/admin/repositories/tenant_key_repository.py
@@ -1,5 +1,8 @@
"""Data-access layer for tenant keys."""
+from datetime import datetime, timezone
+
+from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from admin.models import TenantKey
@@ -18,9 +21,6 @@ async def add(self, key: TenantKey) -> None:
async def update_status(self, tenant_id: int, kid: str, status: str) -> int:
"""Update key status for a tenant key; returns number of rows changed."""
- from datetime import datetime, timezone
-
- from sqlalchemy import update
stmt = (
update(TenantKey)
diff --git a/oid4vc/auth_server/admin/repositories/wallet_provider_repository.py b/oid4vc/auth_server/admin/repositories/wallet_provider_repository.py
new file mode 100644
index 000000000..4312a225a
--- /dev/null
+++ b/oid4vc/auth_server/admin/repositories/wallet_provider_repository.py
@@ -0,0 +1,54 @@
+"""Repository for wallet provider allow list."""
+
+from typing import Sequence
+
+from sqlalchemy import delete, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from admin.models import WalletProvider
+
+
+class WalletProviderRepository:
+ """Data-access layer for the wallet_provider allow list table."""
+
+ def __init__(self, session: AsyncSession) -> None:
+ """Initialize the repository with a database session."""
+ self.session = session
+
+ async def list(self, active_only: bool = False) -> Sequence[WalletProvider]:
+ """Return all wallet providers, optionally filtered to active only."""
+ stmt = select(WalletProvider)
+ if active_only:
+ stmt = stmt.where(WalletProvider.active.is_(True))
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def get(self, id: int) -> WalletProvider | None:
+ """Fetch a single wallet provider by primary key."""
+ result = await self.session.execute(
+ select(WalletProvider).where(WalletProvider.id == id)
+ )
+ return result.scalar_one_or_none()
+
+ async def get_by_iss(self, iss: str) -> WalletProvider | None:
+ """Look up a wallet provider by its issuer identifier."""
+ result = await self.session.execute(
+ select(WalletProvider).where(WalletProvider.iss == iss)
+ )
+ return result.scalar_one_or_none()
+
+ async def update_values(self, id: int, values: dict) -> int:
+ """Apply a partial update and return the number of rows affected."""
+ if not values:
+ return 0
+ res = await self.session.execute(
+ update(WalletProvider).where(WalletProvider.id == id).values(**values)
+ )
+ return res.rowcount or 0
+
+ async def delete(self, id: int) -> int:
+ """Delete a wallet provider by pkey and return the number of rows removed."""
+ res = await self.session.execute(
+ delete(WalletProvider).where(WalletProvider.id == id)
+ )
+ return res.rowcount or 0
diff --git a/oid4vc/auth_server/admin/routers/internal.py b/oid4vc/auth_server/admin/routers/internal.py
index 1912ebf52..092e40941 100644
--- a/oid4vc/auth_server/admin/routers/internal.py
+++ b/oid4vc/auth_server/admin/routers/internal.py
@@ -1,6 +1,6 @@
-"""API for tenant SERVICE helpers: DB info, JWKS, JWT signing."""
+"""API for tenant SERVICE helpers: DB info, JWKS, JWT signing, wallet provider lookup."""
-from fastapi import APIRouter, Depends, Path
+from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from admin.deps import get_db_session
@@ -11,13 +11,17 @@
TenantJwksResponse,
)
from admin.security.bearer import require_internal_auth
-from admin.services.internal_service import get_tenant_db, get_tenant_jwks
+from admin.services.internal_service import (
+ get_tenant_db,
+ get_tenant_jwks,
+ lookup_wallet_provider,
+)
from admin.services.signing_service import sign_tenant_jwt
-router = APIRouter(prefix="/tenants/{uid}", dependencies=[Depends(require_internal_auth)])
+router = APIRouter(dependencies=[Depends(require_internal_auth)])
-@router.get("/db", response_model=TenantDbResponse)
+@router.get("/tenants/{uid}/db", response_model=TenantDbResponse)
async def get_db(
uid: str = Path(...),
db: AsyncSession = Depends(get_db_session),
@@ -26,7 +30,7 @@ async def get_db(
return await get_tenant_db(db, uid)
-@router.get("/jwks", response_model=TenantJwksResponse)
+@router.get("/tenants/{uid}/jwks", response_model=TenantJwksResponse)
async def get_jwks(
uid: str = Path(...),
db: AsyncSession = Depends(get_db_session),
@@ -35,7 +39,11 @@ async def get_jwks(
return await get_tenant_jwks(db, uid)
-@router.post("/jwts", response_model=JwtSignResponse, response_model_exclude_none=True)
+@router.post(
+ "/tenants/{uid}/jwts",
+ response_model=JwtSignResponse,
+ response_model_exclude_none=True,
+)
async def sign_jwt(
body: JwtSignRequest,
uid: str = Path(...),
@@ -43,3 +51,16 @@ async def sign_jwt(
):
"""Sign a JWT for the tenant."""
return await sign_tenant_jwt(db, uid, body)
+
+
+@router.get("/wallet-providers/lookup")
+async def wallet_provider_lookup(
+ iss: str = Query(...),
+ kid: str | None = Query(None),
+ db: AsyncSession = Depends(get_db_session),
+):
+ """Look up a wallet provider key by iss + optional kid from cache."""
+ result = await lookup_wallet_provider(db, iss, kid)
+ if not result:
+ return {"found": False}
+ return {"found": True, **result}
diff --git a/oid4vc/auth_server/admin/routers/migrations.py b/oid4vc/auth_server/admin/routers/migrations.py
index 68ab4ef3d..b99c43556 100644
--- a/oid4vc/auth_server/admin/routers/migrations.py
+++ b/oid4vc/auth_server/admin/routers/migrations.py
@@ -9,6 +9,9 @@
from admin.schemas.migration import MigrationAction, MigrationRequest
from admin.services.alembic_service import run_tenant_migration
from admin.utils.db_utils import resolve_tenant_urls
+from core.utils.logging import get_logger
+
+logger = get_logger(__name__)
router = APIRouter(dependencies=[Depends(require_admin_auth)])
@@ -33,11 +36,17 @@ async def migrate_tenant(
sync_url=sync_url, schema=schema, action="upgrade", rev=rev
)
else:
+ if not body.confirm:
+ raise HTTPException(
+ status_code=400,
+ detail="downgrade requires confirm=true",
+ )
rev = body.rev or "-1"
run_tenant_migration(
sync_url=sync_url, schema=schema, action="downgrade", rev=rev
)
except Exception as ex:
- raise HTTPException(status_code=500, detail=f"Alembic failed: {ex}") from ex
+ logger.exception("Alembic migration failed for tenant %s", uid)
+ raise HTTPException(status_code=500, detail="migration_failed") from ex
return {"status": "ok", "action": body.action, "rev": rev, "tenant": uid}
diff --git a/oid4vc/auth_server/admin/routers/tenants.py b/oid4vc/auth_server/admin/routers/tenants.py
index de91c496e..c5fb0eb59 100644
--- a/oid4vc/auth_server/admin/routers/tenants.py
+++ b/oid4vc/auth_server/admin/routers/tenants.py
@@ -15,7 +15,7 @@
@router.get("/tenants", response_model=list[TenantOut])
async def list_tenants(db: AsyncSession = Depends(get_db_session)):
- """List tenants via repository."""
+ """List tenants."""
svc = TenantService(db)
rows = await svc.list()
return [TenantOut.model_validate(r) for r in rows]
@@ -23,7 +23,7 @@ async def list_tenants(db: AsyncSession = Depends(get_db_session)):
@router.get("/tenants/{uid}", response_model=TenantOut)
async def get_tenant(uid: str, db: AsyncSession = Depends(get_db_session)):
- """Get a specific tenant via repository."""
+ """Get a tenant by uid."""
svc = TenantService(db)
row = await svc.get(uid)
if not row:
@@ -33,7 +33,7 @@ async def get_tenant(uid: str, db: AsyncSession = Depends(get_db_session)):
@router.post("/tenants", response_model=TenantOut, status_code=201)
async def create_tenant(body: TenantIn, db: AsyncSession = Depends(get_db_session)):
- """Create a new tenant via repository."""
+ """Create a tenant."""
svc = TenantService(db)
row = await svc.create(body)
return TenantOut.model_validate(row)
@@ -43,7 +43,7 @@ async def create_tenant(body: TenantIn, db: AsyncSession = Depends(get_db_sessio
async def update_tenant(
uid: str, body: TenantIn, db: AsyncSession = Depends(get_db_session)
):
- """Update a tenant via repository."""
+ """Update a tenant."""
svc = TenantService(db)
await svc.update(uid, body)
return {"status": "updated", "uid": uid}
@@ -51,7 +51,7 @@ async def update_tenant(
@router.delete("/tenants/{uid}")
async def delete_tenant(uid: str, db: AsyncSession = Depends(get_db_session)):
- """Delete a tenant via repository."""
+ """Delete a tenant."""
svc = TenantService(db)
deleted = await svc.delete(uid)
if deleted == 0:
@@ -66,7 +66,7 @@ async def delete_tenant(uid: str, db: AsyncSession = Depends(get_db_session)):
@router.get("/tenants/{uid}/keys")
async def get_tenant_keys(uid: str, db: AsyncSession = Depends(get_db_session)):
- """Get jwks for a tenant via service."""
+ """Get JWKS for a tenant."""
return await get_tenant_jwks(db, uid)
@@ -74,7 +74,7 @@ async def get_tenant_keys(uid: str, db: AsyncSession = Depends(get_db_session)):
async def generate_tenant_keypair(
uid: str, body: KeyGenIn, db: AsyncSession = Depends(get_db_session)
):
- """Generate a keypair for a tenant via service."""
+ """Generate a signing keypair."""
svc = TenantService(db)
return await svc.generate_keypair(uid, body)
@@ -98,7 +98,7 @@ async def update_key_status(
@router.get("/tenants/{uid}/clients", response_model=list[ClientOut])
async def list_clients(uid: str, db: AsyncSession = Depends(get_db_session)):
- """List clients via repository."""
+ """List clients."""
svc = TenantService(db)
rows = await svc.list_clients(uid)
return [ClientOut.model_validate(r) for r in rows]
@@ -108,7 +108,7 @@ async def list_clients(uid: str, db: AsyncSession = Depends(get_db_session)):
async def get_client(
uid: str, client_id: str, db: AsyncSession = Depends(get_db_session)
):
- """Get a specific client via repository."""
+ """Get a client by ID."""
svc = TenantService(db)
row = await svc.get_client(uid, client_id)
if not row:
@@ -120,7 +120,7 @@ async def get_client(
async def create_client(
uid: str, body: ClientIn, db: AsyncSession = Depends(get_db_session)
):
- """Create a new client via repository."""
+ """Create a client."""
svc = TenantService(db)
row = await svc.create_client(uid, body)
return ClientOut.model_validate(row)
@@ -130,7 +130,7 @@ async def create_client(
async def update_client(
uid: str, client_id: str, body: ClientIn, db: AsyncSession = Depends(get_db_session)
):
- """Update a client via repository."""
+ """Update a client."""
svc = TenantService(db)
await svc.update_client(uid, client_id, body)
return {"status": "updated", "client_id": client_id}
@@ -140,7 +140,7 @@ async def update_client(
async def delete_client(
uid: str, client_id: str, db: AsyncSession = Depends(get_db_session)
):
- """Delete a client via repository."""
+ """Delete a client."""
svc = TenantService(db)
deleted = await svc.delete_client(uid, client_id)
if deleted == 0:
diff --git a/oid4vc/auth_server/admin/routers/wallet_providers.py b/oid4vc/auth_server/admin/routers/wallet_providers.py
new file mode 100644
index 000000000..c5f6a98d6
--- /dev/null
+++ b/oid4vc/auth_server/admin/routers/wallet_providers.py
@@ -0,0 +1,66 @@
+"""Admin CRUD endpoints for trusted wallet providers."""
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from admin.deps import get_db_session
+from admin.schemas.wallet_provider import (
+ WalletProviderIn,
+ WalletProviderOut,
+ WalletProviderUpdate,
+)
+from admin.security.bearer import require_admin_auth
+from admin.services.wallet_provider_service import WalletProviderService
+
+router = APIRouter(dependencies=[Depends(require_admin_auth)])
+
+
+@router.get("/wallet-providers", response_model=list[WalletProviderOut])
+async def list_wallet_providers(
+ active_only: bool = Query(False),
+ db: AsyncSession = Depends(get_db_session),
+):
+ """List all trusted wallet providers, with optional active-only filter."""
+ svc = WalletProviderService(db)
+ rows = await svc.list(active_only=active_only)
+ return [WalletProviderOut.model_validate(r) for r in rows]
+
+
+@router.post("/wallet-providers", response_model=WalletProviderOut, status_code=201)
+async def create_wallet_provider(
+ body: WalletProviderIn,
+ db: AsyncSession = Depends(get_db_session),
+):
+ """Register a new trusted wallet provider."""
+ row = await WalletProviderService(db).create(body)
+ return WalletProviderOut.model_validate(row)
+
+
+@router.get("/wallet-providers/{provider_id}", response_model=WalletProviderOut)
+async def get_wallet_provider(
+ provider_id: int,
+ db: AsyncSession = Depends(get_db_session),
+):
+ """Retrieve a single wallet provider by ID."""
+ row = await WalletProviderService(db).get(provider_id)
+ return WalletProviderOut.model_validate(row)
+
+
+@router.patch("/wallet-providers/{provider_id}", response_model=WalletProviderOut)
+async def update_wallet_provider(
+ provider_id: int,
+ body: WalletProviderUpdate,
+ db: AsyncSession = Depends(get_db_session),
+):
+ """Partially update a wallet provider (e.g. rotate key or deactivate)."""
+ row = await WalletProviderService(db).update(provider_id, body)
+ return WalletProviderOut.model_validate(row)
+
+
+@router.delete("/wallet-providers/{provider_id}", status_code=204)
+async def delete_wallet_provider(
+ provider_id: int,
+ db: AsyncSession = Depends(get_db_session),
+):
+ """Remove a wallet provider from the allow list."""
+ await WalletProviderService(db).delete(provider_id)
diff --git a/oid4vc/auth_server/admin/schemas/client.py b/oid4vc/auth_server/admin/schemas/client.py
index df6e814a5..94707a6d9 100644
--- a/oid4vc/auth_server/admin/schemas/client.py
+++ b/oid4vc/auth_server/admin/schemas/client.py
@@ -13,15 +13,15 @@ class ClientIn(BaseModel):
default=None,
)
client_auth_method: str | None = Field(
- description="Auth method: client_secret_basic|client_secret_jwt|private_key_jwt",
+ description="Auth method: client_secret_basic|private_key_jwt",
default=None,
)
client_auth_signing_alg: str | None = Field(
- description="e.g., ES256 or HS256",
+ description="e.g., ES256",
default=None,
)
client_secret: str | None = Field(
- description="For client_secret_basic or client_secret_jwt",
+ description="For client_secret_basic",
default=None,
)
jwks: dict[str, Any] | None = Field(
diff --git a/oid4vc/auth_server/admin/schemas/internal.py b/oid4vc/auth_server/admin/schemas/internal.py
index 81730820e..0bcc30fbe 100644
--- a/oid4vc/auth_server/admin/schemas/internal.py
+++ b/oid4vc/auth_server/admin/schemas/internal.py
@@ -1,20 +1,20 @@
-"""Schemas for signing JWTs for tenants."""
+"""Internal API schemas."""
from typing import Literal
from pydantic import BaseModel
class JwtSignRequest(BaseModel):
- """Payload for signing a JWT."""
+ """JWT signing input."""
- alg: Literal["ES256"] | None = None
+ alg: Literal["ES256", "ES384", "EdDSA"] | None = None
kid: str | None = None
claims: dict
ttl_seconds: int | None = None # if exp not in claims
class JwtSignResponse(BaseModel):
- """Response for signing a JWT."""
+ """Signed JWT output."""
jwt: str
kid: str
@@ -23,13 +23,13 @@ class JwtSignResponse(BaseModel):
class TenantDbResponse(BaseModel):
- """Response model for tenant database connection."""
+ """Tenant DB coordinates."""
db_url: str
db_schema: str
class TenantJwksResponse(BaseModel):
- """Response model for tenant JWKS (JSON Web Key Set)."""
+ """Tenant public keyset."""
keys: list[dict]
diff --git a/oid4vc/auth_server/admin/schemas/migration.py b/oid4vc/auth_server/admin/schemas/migration.py
index 01df439f4..e9be36c37 100644
--- a/oid4vc/auth_server/admin/schemas/migration.py
+++ b/oid4vc/auth_server/admin/schemas/migration.py
@@ -16,3 +16,4 @@ class MigrationRequest(BaseModel):
action: MigrationAction
rev: str | None = None
+ confirm: bool = False
diff --git a/oid4vc/auth_server/admin/schemas/wallet_provider.py b/oid4vc/auth_server/admin/schemas/wallet_provider.py
new file mode 100644
index 000000000..8c37a7bf8
--- /dev/null
+++ b/oid4vc/auth_server/admin/schemas/wallet_provider.py
@@ -0,0 +1,104 @@
+"""Wallet provider schemas."""
+
+from datetime import datetime
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+
+class WalletProviderIn(BaseModel):
+ """Create input."""
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "iss": "https://wallet.example.com",
+ "jwks": {
+ "keys": [
+ {
+ "kid": "wallet-example-key-1",
+ "kty": "EC",
+ "crv": "P-256",
+ "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
+ "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
+ }
+ ]
+ },
+ "name": "Example Wallet Provider",
+ "active": True,
+ }
+ }
+ )
+
+ iss: str = Field(description="Wallet provider issuer identifier (URL)")
+ jwks: dict[str, Any] | None = Field(default=None, description="Inline JWKS document")
+ jwks_uri: str | None = Field(
+ default=None, description="URL to fetch the provider's JWKS"
+ )
+ name: str | None = Field(default=None, description="Display name for the provider")
+ active: bool = Field(default=True, description="Whether this provider is active")
+
+ @model_validator(mode="after")
+ def _require_jwks_or_uri(self):
+ if not self.jwks and not self.jwks_uri:
+ raise ValueError("Either jwks or jwks_uri must be provided")
+ return self
+
+
+class WalletProviderUpdate(BaseModel):
+ """Partial update input."""
+
+ model_config = ConfigDict(
+ json_schema_extra={
+ "example": {
+ "name": "Example Wallet Provider (v2)",
+ "active": False,
+ }
+ }
+ )
+
+ iss: str | None = Field(default=None, description="Updated issuer identifier")
+ jwks: dict[str, Any] | None = Field(
+ default=None, description="Updated inline JWKS document"
+ )
+ jwks_uri: str | None = Field(default=None, description="Updated JWKS URI")
+ name: str | None = Field(default=None, description="Updated display name")
+ active: bool | None = Field(default=None, description="Updated active status")
+
+
+class WalletProviderOut(BaseModel):
+ """API response."""
+
+ model_config = ConfigDict(
+ from_attributes=True,
+ json_schema_extra={
+ "example": {
+ "id": 1,
+ "iss": "https://wallet.example.com",
+ "jwks": {
+ "keys": [
+ {
+ "kid": "wallet-example-key-1",
+ "kty": "EC",
+ "crv": "P-256",
+ "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU",
+ "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0",
+ }
+ ]
+ },
+ "name": "Example Wallet Provider",
+ "active": True,
+ "created_at": "2026-04-27T14:30:00",
+ "updated_at": None,
+ }
+ },
+ )
+
+ id: int
+ iss: str
+ jwks: dict[str, Any] | None = None
+ jwks_uri: str | None = None
+ name: str | None = None
+ active: bool
+ created_at: datetime
+ updated_at: datetime | None = None
diff --git a/oid4vc/auth_server/admin/security/bearer.py b/oid4vc/auth_server/admin/security/bearer.py
index 33df04574..56dca7cdc 100644
--- a/oid4vc/auth_server/admin/security/bearer.py
+++ b/oid4vc/auth_server/admin/security/bearer.py
@@ -1,5 +1,7 @@
"""Bearer auth dependencies for Admin API (router-level guards)."""
+import secrets
+
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -9,31 +11,24 @@
_security = HTTPBearer(auto_error=False)
-def require_internal_auth(
- credentials: HTTPAuthorizationCredentials | None = Depends(_security),
-) -> bool:
- """Validate internal routes via Bearer from settings."""
- token = credentials.credentials if credentials else ""
- expected = getattr(settings, "INTERNAL_AUTH_TOKEN", "")
- if not token or token != expected:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="unauthorized",
- headers={"WWW-Authenticate": "Bearer"},
- )
- return True
-
-
-def require_admin_auth(
- credentials: HTTPAuthorizationCredentials | None = Depends(_security),
-) -> bool:
- """Validate admin routes via Bearer (swap to OIDC later)."""
- token = credentials.credentials if credentials else ""
- expected = getattr(settings, "MANAGE_AUTH_TOKEN", "")
- if not token or token != expected:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="unauthorized",
- headers={"WWW-Authenticate": "Bearer"},
- )
- return True
+def _bearer_guard(settings_attr: str):
+ """Create a bearer token dependency checking against a settings value."""
+
+ def _guard(
+ credentials: HTTPAuthorizationCredentials | None = Depends(_security),
+ ) -> bool:
+ token = credentials.credentials if credentials else ""
+ expected = getattr(settings, settings_attr, "")
+ if not token or not expected or not secrets.compare_digest(token, expected):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="unauthorized",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+ return True
+
+ return _guard
+
+
+require_internal_auth = _bearer_guard("INTERNAL_AUTH_TOKEN")
+require_admin_auth = _bearer_guard("MANAGE_AUTH_TOKEN")
diff --git a/oid4vc/auth_server/admin/services/client_service.py b/oid4vc/auth_server/admin/services/client_service.py
index c5d40aa0c..617735313 100644
--- a/oid4vc/auth_server/admin/services/client_service.py
+++ b/oid4vc/auth_server/admin/services/client_service.py
@@ -5,6 +5,7 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
+from admin.config import settings
from admin.repositories.client_repository import ClientRepository
from admin.schemas.client import ClientIn
from core.consts import CLIENT_AUTH_METHODS, ClientAuthMethod
@@ -16,7 +17,7 @@ class ClientService:
"""Client orchestration."""
def __init__(self, session: AsyncSession):
- """Constructor."""
+ """Bind to session."""
self.session = session
self.repo = ClientRepository(session)
@@ -33,24 +34,24 @@ async def create(self, data: ClientIn) -> Client:
if not signing_alg:
if method == ClientAuthMethod.PRIVATE_KEY_JWT:
signing_alg = "ES256"
- elif method == ClientAuthMethod.CLIENT_SECRET_JWT:
- signing_alg = "HS256"
# Validate fields by method
secret_hash: str | None = None
if method == ClientAuthMethod.PRIVATE_KEY_JWT:
if not (data.jwks or data.jwks_uri):
raise HTTPException(status_code=400, detail="jwks_or_uri_required")
- elif method in (
- ClientAuthMethod.CLIENT_SECRET_JWT,
- ClientAuthMethod.CLIENT_SECRET_BASIC,
- ):
+ elif method == ClientAuthMethod.CLIENT_SECRET_BASIC:
if not data.client_secret:
raise HTTPException(status_code=400, detail="client_secret_required")
- if method == ClientAuthMethod.CLIENT_SECRET_BASIC:
- secret_hash = hash_secret_pbkdf2(data.client_secret)
- else:
- secret_hash = data.client_secret
+ if len(data.client_secret) < settings.MIN_CLIENT_SECRET_LENGTH:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ "client_secret_too_short "
+ f"(min {settings.MIN_CLIENT_SECRET_LENGTH} chars)"
+ ),
+ )
+ secret_hash = hash_secret_pbkdf2(data.client_secret)
client_id = data.client_id or uuid.uuid4().hex
@@ -72,12 +73,12 @@ async def create(self, data: ClientIn) -> Client:
return client
async def list(self) -> list[Client]:
- """List all clients."""
+ """All clients."""
rows = await self.repo.list()
return list(rows)
async def get(self, client_id: str) -> Client | None:
- """Get client by client_id."""
+ """Single client or None."""
return await self.repo.get_by_client_id(client_id)
async def update(self, client_id: str, data: ClientIn) -> int:
@@ -88,6 +89,16 @@ async def update(self, client_id: str, data: ClientIn) -> int:
values = {
k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None
}
+ if "client_secret" in values:
+ if len(values["client_secret"]) < settings.MIN_CLIENT_SECRET_LENGTH:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ "client_secret_too_short "
+ f"(min {settings.MIN_CLIENT_SECRET_LENGTH} chars)",
+ ),
+ )
+ values["client_secret"] = hash_secret_pbkdf2(values["client_secret"])
changed = await self.repo.update_values(row.id, values)
await self.session.commit()
return changed
diff --git a/oid4vc/auth_server/admin/services/internal_service.py b/oid4vc/auth_server/admin/services/internal_service.py
index e2bd55b0a..6714c2a24 100644
--- a/oid4vc/auth_server/admin/services/internal_service.py
+++ b/oid4vc/auth_server/admin/services/internal_service.py
@@ -3,18 +3,55 @@
from typing import Dict, List
from datetime import datetime, timezone, timedelta
-from authlib.jose import JsonWebKey
+from joserfc import jwk
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from admin.config import settings
-from admin.models import Tenant, TenantKey
+from admin.models import Tenant, TenantKey, WalletProvider
from admin.utils.db_utils import resolve_tenant_urls
from admin.utils.keys import is_time_valid
+from core.security.jwks_cache import JWKSCache
+from core.utils.logging import get_logger
+
+logger = get_logger(__name__)
MAX_TTL_SECONDS = 3600
+# Cache for wallet provider JWKS (keyed by iss)
+_provider_jwks_cache = JWKSCache(ttl=300)
+
+
+async def load_wallet_providers(session: AsyncSession) -> None:
+ """Load all active wallet providers into the JWKS cache (startup)."""
+ rows = (
+ (
+ await session.execute(
+ select(WalletProvider).where(WalletProvider.active.is_(True))
+ )
+ )
+ .scalars()
+ .all()
+ )
+ for row in rows:
+ _provider_jwks_cache.put(
+ row.iss,
+ row.jwks,
+ jwks_uri=row.jwks_uri,
+ )
+ logger.info("Loaded %d wallet providers into cache", len(rows))
+
+
+def refresh_provider_cache(iss: str, jwks: dict | None, jwks_uri: str | None) -> None:
+ """Refresh a single provider's cache entry (called after create/update)."""
+ _provider_jwks_cache.put(iss, jwks, jwks_uri=jwks_uri)
+
+
+def invalidate_provider_cache(iss: str) -> None:
+ """Remove a provider from cache (called after delete/deactivate)."""
+ _provider_jwks_cache.invalidate(iss)
+
async def get_tenant_db(session: AsyncSession, uid: str) -> Dict[str, str]:
"""Return tenant DB URL and schema."""
@@ -60,8 +97,58 @@ def _include(row: TenantKey) -> bool:
for row in rows:
if not row.public_jwk or not _include(row):
continue
- jwk_obj = JsonWebKey.import_key(row.public_jwk)
- jwk_dict = jwk_obj.as_dict(is_private=False, kid=row.kid, alg=row.alg, use="sig")
+ jwk_obj = jwk.import_key(row.public_jwk)
+ jwk_dict = jwk_obj.as_dict(private=False, kid=row.kid, alg=row.alg, use="sig")
if jwk_dict is not None:
keys.append(jwk_dict)
return {"keys": keys}
+
+
+async def _revalidate_provider(session: AsyncSession, iss: str) -> bool:
+ """Re-read a provider from the DB when its cache entry is missing or stale.
+
+ Keeps deactivations visible to every worker process, not just the one that
+ served the write.
+ """
+ if not _provider_jwks_cache.is_stale(iss):
+ return True
+
+ row = (
+ await session.execute(select(WalletProvider).where(WalletProvider.iss == iss))
+ ).scalar_one_or_none()
+ if not row or not row.active:
+ _provider_jwks_cache.invalidate(iss)
+ return False
+
+ _provider_jwks_cache.put(row.iss, row.jwks, jwks_uri=row.jwks_uri)
+ return True
+
+
+async def lookup_wallet_provider(
+ session: AsyncSession, iss: str, kid: str | None = None
+) -> dict | None:
+ """Look up wallet provider key(s) by iss (+ optional kid).
+
+ When *kid* is provided, returns a single key dict under ``public_key``.
+ When *kid* is omitted, returns all cached keys under ``keys``.
+ """
+ if not await _revalidate_provider(session, iss):
+ logger.info("No active wallet provider for %s", iss)
+ return None
+
+ if kid:
+ key = await _provider_jwks_cache.get_key(iss, kid)
+ if key:
+ return {"iss": iss, "public_key": key.as_dict(private=False)}
+ logger.info("kid %r not found for provider %s", kid, iss)
+ return None
+
+ # No kid — return full keyset for trial verification
+ key_set = _provider_jwks_cache.get_keyset(iss)
+ if not key_set or not key_set.keys:
+ logger.info("No keys cached for provider %s", iss)
+ return None
+ return {
+ "iss": iss,
+ "keys": [k.as_dict(private=False) for k in key_set.keys],
+ }
diff --git a/oid4vc/auth_server/admin/services/signing_service.py b/oid4vc/auth_server/admin/services/signing_service.py
index 977ab3022..0347f8f31 100644
--- a/oid4vc/auth_server/admin/services/signing_service.py
+++ b/oid4vc/auth_server/admin/services/signing_service.py
@@ -2,7 +2,7 @@
from datetime import datetime, timezone
-from authlib.jose import JsonWebKey, jwt
+from joserfc import jwk, jwt
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +11,7 @@
from admin.schemas.internal import JwtSignRequest, JwtSignResponse
from admin.utils.crypto import decrypt_private_pem
from admin.utils.keys import select_signing_key
+from core.consts import SUPPORTED_SIGNING_ALGS, ALG_KEY_FAMILY
MAX_TTL_SECONDS = 3600
@@ -36,7 +37,7 @@ async def sign_tenant_jwt(
raise HTTPException(status_code=404, detail="signing_key_not_found")
alg = req.alg or key.alg
- if alg != "ES256":
+ if alg not in SUPPORTED_SIGNING_ALGS:
raise HTTPException(status_code=400, detail="unsupported_alg")
# Claims validation
@@ -66,8 +67,9 @@ async def sign_tenant_jwt(
# Sign
pem = decrypt_private_pem(key.private_pem_enc) # type: ignore
- jwk_key = JsonWebKey.import_key(pem) # type: ignore
+ key_family = ALG_KEY_FAMILY.get(alg, "EC")
+ jwk_key = jwk.import_key(pem, key_family)
header = {"alg": alg, "kid": key.kid, "typ": "JWT"}
- token = jwt.encode(header, claims, jwk_key).decode()
+ token = jwt.encode(header, claims, jwk_key)
return JwtSignResponse(jwt=token, kid=key.kid, alg=alg, exp=exp_val)
diff --git a/oid4vc/auth_server/admin/services/tenant_service.py b/oid4vc/auth_server/admin/services/tenant_service.py
index 34dce8c0d..bc9e83b0f 100644
--- a/oid4vc/auth_server/admin/services/tenant_service.py
+++ b/oid4vc/auth_server/admin/services/tenant_service.py
@@ -2,14 +2,15 @@
from __future__ import annotations
+import asyncio
import secrets
import uuid
from datetime import datetime, timezone
import psycopg
-from authlib.jose import JsonWebKey
+from joserfc import jwk
from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.hazmat.primitives.asymmetric import ec, ed25519
from fastapi import HTTPException
from psycopg import sql
from sqlalchemy.exc import IntegrityError
@@ -26,14 +27,18 @@
from admin.utils.crypto import encrypt_db_password, encrypt_private_pem
from admin.utils.db_utils import build_sync_url, resolve_tenant_urls, url_to_dsn
from core.db.cached_session import cached_session
+from core.consts import SUPPORTED_SIGNING_ALGS, ALG_KEY_FAMILY
from core.models import Client
+from core.utils.logging import get_logger
+
+logger = get_logger(__name__)
class TenantService:
"""Tenant orchestration."""
def __init__(self, session: AsyncSession):
- """Constructor."""
+ """Bind to session."""
self.session = session
self.repo = TenantRepository(session)
@@ -103,15 +108,18 @@ async def create(self, data: TenantIn) -> Tenant:
db_password = secrets.token_urlsafe(32)
try:
- self._provision(
- db_name=db_name,
- db_schema=db_schema,
- db_user=db_user,
- db_password=db_password,
+ await asyncio.to_thread(
+ lambda: self._provision(
+ db_name=db_name,
+ db_schema=db_schema,
+ db_user=db_user,
+ db_password=db_password,
+ )
)
except Exception as ex:
await self.session.rollback()
- raise HTTPException(status_code=500, detail=f"provision_failed: {ex}")
+ logger.exception("Tenant provision failed: %s", ex)
+ raise HTTPException(status_code=500, detail="provision_failed")
# Run Alembic migrations via shared service
alembic_url = build_sync_url(db_name, db_user, db_password)
@@ -124,7 +132,8 @@ async def create(self, data: TenantIn) -> Tenant:
)
except Exception as ex:
await self.session.rollback()
- raise HTTPException(status_code=500, detail=f"migration_failed: {ex}")
+ logger.exception("Tenant migration failed: %s", ex)
+ raise HTTPException(status_code=500, detail="migration_failed")
tenant.db_name = db_name
tenant.db_schema = db_schema
@@ -166,16 +175,24 @@ async def delete(self, uid: str) -> int:
return deleted
async def generate_keypair(self, uid: str, body) -> dict:
- """Generate and store a tenant signing keypair (ES256)."""
+ """Generate and store a tenant signing keypair."""
+
repo = self.repo
tenant = await repo.get_by_uid(uid)
if not tenant:
raise HTTPException(status_code=404, detail="tenant_not_found")
- if body.alg != "ES256":
+ if body.alg not in SUPPORTED_SIGNING_ALGS:
raise HTTPException(status_code=400, detail="unsupported_alg")
- prv = ec.generate_private_key(ec.SECP256R1())
+ # Generate private key based on algorithm
+ if body.alg == "EdDSA":
+ prv = ed25519.Ed25519PrivateKey.generate()
+ elif body.alg == "ES384":
+ prv = ec.generate_private_key(ec.SECP384R1())
+ else: # ES256
+ prv = ec.generate_private_key(ec.SECP256R1())
+
private_pem = prv.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
@@ -183,12 +200,8 @@ async def generate_keypair(self, uid: str, body) -> dict:
).decode("utf-8")
private_pem_enc = encrypt_private_pem(private_pem)
- pub = prv.public_key()
- public_pem = pub.public_bytes(
- encoding=serialization.Encoding.PEM,
- format=serialization.PublicFormat.SubjectPublicKeyInfo,
- ).decode("utf-8")
- public_jwk = JsonWebKey.import_key(public_pem).as_dict() # type: ignore
+ key_family = ALG_KEY_FAMILY[body.alg]
+ public_jwk = jwk.import_key(private_pem, key_family).as_dict(private=False) # type: ignore
if public_jwk is None:
raise HTTPException(status_code=500, detail="Failed to create JWK from PEM")
@@ -246,72 +259,43 @@ async def update_key_status(self, uid: str, kid: str, new_status: str) -> dict:
await self.session.commit()
return {"status": new_status, "kid": kid, "uid": uid}
- async def create_client(self, uid: str, data: ClientIn) -> Client:
- """Create a client record in the tenant DB."""
+ async def _with_client_service(self, uid: str, op: str, callback):
+ """Resolve tenant, open a session, run callback(ClientService)."""
tenant = await self.repo.get_by_uid(uid)
if not tenant:
raise HTTPException(status_code=404, detail="tenant_not_found")
-
async_url, _, schema = resolve_tenant_urls(tenant)
try:
async with cached_session(async_url, schema) as tenant_db:
- svc = ClientService(tenant_db)
- return await svc.create(data)
+ return await callback(ClientService(tenant_db))
+ except HTTPException:
+ raise
except Exception as ex:
- raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}")
+ logger.exception("Client %s failed for tenant %s: %s", op, uid, ex)
+ raise HTTPException(status_code=500, detail="onboard_failed")
+
+ async def create_client(self, uid: str, data: ClientIn) -> Client:
+ """Create a client record in the tenant DB."""
+ return await self._with_client_service(
+ uid, "create", lambda svc: svc.create(data)
+ )
async def list_clients(self, uid: str) -> list[Client]:
"""List all clients."""
- tenant = await self.repo.get_by_uid(uid)
- if not tenant:
- raise HTTPException(status_code=404, detail="tenant_not_found")
-
- async_url, _, schema = resolve_tenant_urls(tenant)
- try:
- async with cached_session(async_url, schema) as tenant_db:
- svc = ClientService(tenant_db)
- return await svc.list()
- except Exception as ex:
- raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}")
+ return await self._with_client_service(uid, "list", lambda svc: svc.list())
async def get_client(self, uid: str, client_id: str) -> Client | None:
"""Get a specific client."""
- tenant = await self.repo.get_by_uid(uid)
- if not tenant:
- raise HTTPException(status_code=404, detail="tenant_not_found")
-
- async_url, _, schema = resolve_tenant_urls(tenant)
- try:
- async with cached_session(async_url, schema) as tenant_db:
- svc = ClientService(tenant_db)
- return await svc.get(client_id)
- except Exception as ex:
- raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}")
+ return await self._with_client_service(uid, "get", lambda svc: svc.get(client_id))
async def update_client(self, uid: str, client_id: str, data: ClientIn) -> int:
"""Update a client."""
- tenant = await self.repo.get_by_uid(uid)
- if not tenant:
- raise HTTPException(status_code=404, detail="tenant_not_found")
-
- async_url, _, schema = resolve_tenant_urls(tenant)
- try:
- async with cached_session(async_url, schema) as tenant_db:
- svc = ClientService(tenant_db)
- return await svc.update(client_id, data)
- except Exception as ex:
- raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}")
+ return await self._with_client_service(
+ uid, "update", lambda svc: svc.update(client_id, data)
+ )
async def delete_client(self, uid: str, client_id: str) -> int:
"""Delete a client."""
- tenant = await self.repo.get_by_uid(uid)
- if not tenant:
- raise HTTPException(status_code=404, detail="tenant_not_found")
-
- async_url, _, schema = resolve_tenant_urls(tenant)
- try:
- async with cached_session(async_url, schema) as tenant_db:
- svc = ClientService(tenant_db)
- return await svc.delete(client_id)
- except Exception as ex:
- raise HTTPException(status_code=500, detail=f"onboard_failed: {ex}")
+ return await self._with_client_service(
+ uid, "delete", lambda svc: svc.delete(client_id)
+ )
diff --git a/oid4vc/auth_server/admin/services/wallet_provider_service.py b/oid4vc/auth_server/admin/services/wallet_provider_service.py
new file mode 100644
index 000000000..90b454e2e
--- /dev/null
+++ b/oid4vc/auth_server/admin/services/wallet_provider_service.py
@@ -0,0 +1,93 @@
+"""Wallet provider service."""
+
+from fastapi import HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from admin.models import WalletProvider
+from admin.repositories.wallet_provider_repository import WalletProviderRepository
+from admin.schemas.wallet_provider import WalletProviderIn, WalletProviderUpdate
+from admin.services.internal_service import (
+ invalidate_provider_cache,
+ refresh_provider_cache,
+)
+
+
+class WalletProviderService:
+ """Wallet provider CRUD."""
+
+ def __init__(self, session: AsyncSession):
+ """Bind to session."""
+ self.session = session
+ self.repo = WalletProviderRepository(session)
+
+ async def create(self, data: WalletProviderIn) -> WalletProvider:
+ """Create a new wallet provider. Raises 409 if iss already exists."""
+ existing = await self.repo.get_by_iss(data.iss)
+ if existing:
+ raise HTTPException(
+ status_code=409,
+ detail="wallet_provider_exists",
+ )
+
+ provider = WalletProvider(
+ iss=data.iss,
+ jwks=data.jwks,
+ jwks_uri=data.jwks_uri,
+ name=data.name,
+ active=data.active,
+ )
+ self.session.add(provider)
+ await self.session.commit()
+ await self.session.refresh(provider)
+ refresh_provider_cache(provider.iss, provider.jwks, provider.jwks_uri)
+ return provider
+
+ async def list(self, active_only: bool = False) -> list[WalletProvider]:
+ """Return all wallet providers, optionally filtered to active only."""
+ rows = await self.repo.list(active_only=active_only)
+ return list(rows)
+
+ async def get(self, provider_id: int) -> WalletProvider:
+ """Fetch a single wallet provider by ID. Raises 404 if not found."""
+ row = await self.repo.get(provider_id)
+ if not row:
+ raise HTTPException(status_code=404, detail="wallet_provider_not_found")
+ return row
+
+ async def update(
+ self, provider_id: int, data: WalletProviderUpdate
+ ) -> WalletProvider:
+ """Partially update a wallet provider. Raises 404 if not found."""
+ row = await self.repo.get(provider_id)
+ if not row:
+ raise HTTPException(status_code=404, detail="wallet_provider_not_found")
+
+ old_iss = row.iss
+ values = data.model_dump(exclude_none=True)
+ if values:
+ await self.repo.update_values(provider_id, values)
+ await self.session.commit()
+ await self.session.refresh(row)
+ if old_iss != row.iss:
+ invalidate_provider_cache(old_iss)
+ if row.active:
+ refresh_provider_cache(row.iss, row.jwks, row.jwks_uri)
+ else:
+ invalidate_provider_cache(row.iss)
+ return row
+
+ async def delete(self, provider_id: int) -> None:
+ """Delete a wallet provider by ID. Raises 404 if not found."""
+ row = await self.repo.get(provider_id)
+ if not row:
+ raise HTTPException(status_code=404, detail="wallet_provider_not_found")
+ invalidate_provider_cache(row.iss)
+ await self.repo.delete(provider_id)
+ await self.session.commit()
+
+ async def lookup(self, iss: str) -> WalletProvider | None:
+ """Look up an active provider by iss (for attestation verification)."""
+ row = await self.repo.get_by_iss(iss)
+ if row and row.active:
+ return row
+ return None
diff --git a/oid4vc/auth_server/admin/utils/crypto.py b/oid4vc/auth_server/admin/utils/crypto.py
index a856ad193..ab32a6ace 100644
--- a/oid4vc/auth_server/admin/utils/crypto.py
+++ b/oid4vc/auth_server/admin/utils/crypto.py
@@ -1,23 +1,15 @@
"""Crypto helpers (simple mode)."""
-import base64
import os
import secrets
-from authlib.jose import jwk
+from joserfc.jwk import ECKey
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from admin.config import settings
-
-
-def _b64url_decode_padded(s: str) -> bytes:
- return base64.urlsafe_b64decode(s + "===")
-
-
-def _b64url_encode_no_pad(b: bytes) -> str:
- return base64.urlsafe_b64encode(b).decode("utf-8").rstrip("=")
+from core.utils.encoding import b64url_decode, b64url_encode
def _load_key(version: int = 1) -> bytes | None:
@@ -26,7 +18,7 @@ def _load_key(version: int = 1) -> bytes | None:
if not secret:
return None
try:
- return _b64url_decode_padded(secret)
+ return b64url_decode(secret)
except Exception:
return None
@@ -40,7 +32,7 @@ def _aead_encrypt(plaintext: str) -> str:
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
- blob = _b64url_encode_no_pad(nonce + ct)
+ blob = b64url_encode(nonce + ct)
# Always prefix with version, e.g., v1:... or v2:...
return f"v{version}:{blob}"
@@ -49,7 +41,9 @@ def _aead_decrypt(blob: str) -> str:
"""AES-GCM decrypt with version prefix, assume v1 if missing."""
version = 1
b64_blob = blob
+ has_version_prefix = False
if isinstance(blob, str) and blob.startswith("v") and ":" in blob[:6]:
+ has_version_prefix = True
# Parse version prefix, e.g., v2:...
vpart, b64_blob = blob.split(":", 1)
try:
@@ -58,19 +52,27 @@ def _aead_decrypt(blob: str) -> str:
version = 1
key = _load_key(version)
if not key:
- # fallback: if v1 and no key, return as plaintext
+ if has_version_prefix:
+ raise ValueError("decryption key unavailable for version %d" % version)
+ # No version prefix and no key — treat as unencrypted plaintext
return blob
try:
- raw = _b64url_decode_padded(b64_blob)
- except Exception:
+ raw = b64url_decode(b64_blob)
+ except Exception as exc:
+ if has_version_prefix:
+ raise ValueError("failed to decode encrypted blob") from exc
return blob
if len(raw) < 12 + 16:
+ if has_version_prefix:
+ raise ValueError("encrypted blob too short")
return blob
nonce, ct_tag = raw[:12], raw[12:]
aesgcm = AESGCM(key)
try:
return aesgcm.decrypt(nonce, ct_tag, None).decode("utf-8")
- except Exception:
+ except Exception as exc:
+ if has_version_prefix:
+ raise ValueError("AEAD decryption failed") from exc
return blob
@@ -103,7 +105,7 @@ def generate_es256_keypair(kid: str | None = None, encrypt: bool = True) -> dict
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
- public_jwk = jwk.dumps(private_pem, kty="EC", crv="P-256", is_private=False)
+ public_jwk = ECKey.import_key(private_pem).as_dict(private=False)
_kid = kid or f"as-{secrets.token_hex(4)}"
public_jwk["kid"] = _kid
public_jwk["alg"] = "ES256"
@@ -116,5 +118,4 @@ def generate_es256_keypair(kid: str | None = None, encrypt: bool = True) -> dict
"alg": "ES256",
"public_jwk": public_jwk,
"private_pem_enc": private_pem_enc,
- "private_pem": private_pem,
}
diff --git a/oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py b/oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py
new file mode 100644
index 000000000..89d7f6221
--- /dev/null
+++ b/oid4vc/auth_server/alembic/admin/versions/0002_add_wallet_provider.py
@@ -0,0 +1,32 @@
+from alembic import op
+
+revision = "0002_add_wallet_provider"
+down_revision = "0001_init_admin"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS wallet_provider (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ iss TEXT NOT NULL,
+ jwks JSONB,
+ jwks_uri TEXT,
+ name TEXT,
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ,
+ UNIQUE (iss)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_wallet_provider_iss
+ ON wallet_provider (iss);
+
+ CREATE INDEX IF NOT EXISTS idx_wallet_provider_active
+ ON wallet_provider (active);
+ """)
+
+
+def downgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS wallet_provider;")
diff --git a/oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py b/oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py
new file mode 100644
index 000000000..ce4500692
--- /dev/null
+++ b/oid4vc/auth_server/alembic/tenant/versions/0002_tx_code_and_jti_seen.py
@@ -0,0 +1,47 @@
+from alembic import op
+
+revision = "0002_tx_code_and_jti_seen"
+down_revision = "0001_init_tenant"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("""
+ ALTER TABLE pre_auth_code
+ ADD COLUMN IF NOT EXISTS user_pin_attempts INTEGER NOT NULL DEFAULT 0;
+
+ CREATE TABLE IF NOT EXISTS jti_seen (
+ jti TEXT PRIMARY KEY,
+ expires_at TIMESTAMPTZ NOT NULL,
+ metadata JSONB
+ );
+ CREATE INDEX IF NOT EXISTS ix_jti_seen_expires_at
+ ON jti_seen (expires_at);
+
+ DROP TABLE IF EXISTS dpop_jti;
+ """)
+
+
+def downgrade() -> None:
+ op.execute("""
+ CREATE TABLE IF NOT EXISTS dpop_jti (
+ id SERIAL PRIMARY KEY,
+ subject_id INTEGER NOT NULL REFERENCES subject(id)
+ ON UPDATE CASCADE ON DELETE CASCADE,
+ jti TEXT NOT NULL UNIQUE,
+ htm TEXT,
+ htu TEXT,
+ cnf_jkt TEXT,
+ issued_at TIMESTAMPTZ NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_dpop_jti_expires_at
+ ON dpop_jti (expires_at);
+
+ DROP INDEX IF EXISTS ix_jti_seen_expires_at;
+ DROP TABLE IF EXISTS jti_seen;
+
+ ALTER TABLE pre_auth_code
+ DROP COLUMN IF EXISTS user_pin_attempts;
+ """)
diff --git a/oid4vc/auth_server/core/consts.py b/oid4vc/auth_server/core/consts.py
index 4237d1d5e..d0cea1e77 100644
--- a/oid4vc/auth_server/core/consts.py
+++ b/oid4vc/auth_server/core/consts.py
@@ -2,29 +2,38 @@
class OAuth2Flow:
- """OAuth2 grant types."""
+ """Internal flow identifiers."""
PRE_AUTH_CODE = "pre_auth_code"
REFRESH_TOKEN = "refresh_token"
class OAuth2GrantType:
- """OAuth2 grant types."""
+ """Wire-level grant_type URNs."""
PRE_AUTH_CODE = "urn:ietf:params:oauth:grant-type:pre-authorized_code"
REFRESH_TOKEN = "refresh_token"
class ClientAuthMethod:
- """OAuth2 client authentication methods."""
+ """token_endpoint_auth_method values."""
CLIENT_SECRET_BASIC = "client_secret_basic"
PRIVATE_KEY_JWT = "private_key_jwt"
- CLIENT_SECRET_JWT = "client_secret_jwt"
CLIENT_AUTH_METHODS: tuple[str, ...] = (
ClientAuthMethod.CLIENT_SECRET_BASIC,
ClientAuthMethod.PRIVATE_KEY_JWT,
- ClientAuthMethod.CLIENT_SECRET_JWT,
)
+
+SUPPORTED_SIGNING_ALGS: tuple[str, ...] = ("ES256", "EdDSA", "ES384")
+
+# Mapping from signing algorithm to joserfc key family ("EC" or "OKP")
+ALG_KEY_FAMILY: dict[str, str] = {
+ "ES256": "EC",
+ "ES384": "EC",
+ "EdDSA": "OKP",
+}
+
+PBKDF2_ALLOWED_ALGOS: frozenset[str] = frozenset({"sha256", "sha384", "sha512"})
diff --git a/oid4vc/auth_server/core/crypto/crypto.py b/oid4vc/auth_server/core/crypto/crypto.py
index 85e9e61b9..3ef01964e 100644
--- a/oid4vc/auth_server/core/crypto/crypto.py
+++ b/oid4vc/auth_server/core/crypto/crypto.py
@@ -1,18 +1,12 @@
"""Minimal PBKDF2 helpers for hashing and verifying shared secrets."""
-import base64
import hashlib
import hmac
import os
from typing import Tuple
-
-def _b64url_nopad(data: bytes) -> str:
- return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
-
-
-def _b64url_decode_padded(data: str) -> bytes:
- return base64.urlsafe_b64decode(data + "===")
+from core.consts import PBKDF2_ALLOWED_ALGOS
+from core.utils.encoding import b64url_decode, b64url_encode
def hash_secret_pbkdf2(
@@ -29,8 +23,8 @@ def hash_secret_pbkdf2(
salt = os.urandom(salt_len)
dk = hashlib.pbkdf2_hmac(algo, secret.encode("utf-8"), salt, iterations, dklen)
return f"pbkdf2:{algo}:{iterations}$%s$%s" % (
- _b64url_nopad(salt),
- _b64url_nopad(dk),
+ b64url_encode(salt),
+ b64url_encode(dk),
)
@@ -43,8 +37,8 @@ def _parse_pbkdf2(encoded: str) -> Tuple[str, int, bytes, bytes]:
algo, rest2 = rest.split(":", 1)
iter_s, salt_b64, hash_b64 = rest2.split("$")
iterations = int(iter_s)
- salt = _b64url_decode_padded(salt_b64)
- dk = _b64url_decode_padded(hash_b64)
+ salt = b64url_decode(salt_b64)
+ dk = b64url_decode(hash_b64)
return algo, iterations, salt, dk
except Exception as ex:
raise ValueError("invalid pbkdf2 format") from ex
@@ -54,6 +48,10 @@ def verify_secret_pbkdf2(secret: str, encoded: str) -> bool:
"""Verify a PBKDF2-HMAC hash string produced by hash_secret_pbkdf2."""
try:
algo, iterations, salt, expected = _parse_pbkdf2(encoded)
+ if algo not in PBKDF2_ALLOWED_ALGOS:
+ return False
+ if iterations > 1_000_000:
+ return False
dklen = len(expected)
actual = hashlib.pbkdf2_hmac(
algo, secret.encode("utf-8"), salt, iterations, dklen
diff --git a/oid4vc/auth_server/core/db/cached_session.py b/oid4vc/auth_server/core/db/cached_session.py
index d97167680..5fe91334a 100644
--- a/oid4vc/auth_server/core/db/cached_session.py
+++ b/oid4vc/auth_server/core/db/cached_session.py
@@ -1,37 +1,67 @@
-"""Cached session factory."""
+"""LRU-cached async engine pool, keyed by (url, schema)."""
+import asyncio
+from collections import OrderedDict
from contextlib import asynccontextmanager
-from functools import lru_cache
from typing import AsyncIterator
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.ext.asyncio import (
+ AsyncEngine,
+ AsyncSession,
+ async_sessionmaker,
+ create_async_engine,
+)
+_engines: OrderedDict[tuple[str, str], AsyncEngine] = OrderedDict()
+_DEFAULT_MAX = 64
-@lru_cache(maxsize=256)
-def _session_factory(async_url: str, schema: str) -> async_sessionmaker[AsyncSession]:
- """Return a cached sessionmaker for the given database and schema."""
- engine = create_async_engine(
- async_url,
- pool_pre_ping=True,
- connect_args={"server_settings": {"search_path": schema}},
- )
- return async_sessionmaker(engine, expire_on_commit=False)
+
+def _session_factory(
+ async_url: str,
+ schema: str,
+ *,
+ max_engines: int = _DEFAULT_MAX,
+ pool_size: int = 5,
+ max_overflow: int = 10,
+ pool_recycle: int = 1800,
+) -> async_sessionmaker[AsyncSession]:
+ """Get or create a sessionmaker, evicting LRU engines over max."""
+ key = (async_url, schema)
+ if key in _engines:
+ _engines.move_to_end(key)
+ else:
+ _engines[key] = create_async_engine(
+ async_url,
+ pool_pre_ping=True,
+ pool_size=pool_size,
+ max_overflow=max_overflow,
+ pool_recycle=pool_recycle,
+ connect_args={"server_settings": {"search_path": schema}},
+ )
+ while len(_engines) > max_engines:
+ _, evicted = _engines.popitem(last=False)
+ try:
+ asyncio.get_event_loop().create_task(evicted.dispose())
+ except RuntimeError:
+ pass
+ return async_sessionmaker(_engines[key], expire_on_commit=False)
@asynccontextmanager
async def cached_session(async_url: str, schema: str) -> AsyncIterator[AsyncSession]:
- """Yield an AsyncSession backed by a cached factory."""
+ """Context manager for a session from the cached pool."""
session = _session_factory(async_url, schema)()
try:
yield session
+ except Exception:
+ await session.rollback()
+ raise
finally:
await session.close()
async def dispose_cached_engines() -> None:
- """Dispose all cached engines."""
- factories = list(_session_factory.cache_info().cache.keys()) # type: ignore[attr-defined]
- for args in factories:
- sessionmaker = _session_factory(*args)
- await sessionmaker.bind.dispose() # type: ignore[union-attr]
- _session_factory.cache_clear()
+ """Shut down all pooled engines."""
+ for engine in _engines.values():
+ await engine.dispose()
+ _engines.clear()
diff --git a/oid4vc/auth_server/core/db/session.py b/oid4vc/auth_server/core/db/session.py
index 4a0af53eb..a997b6e2c 100644
--- a/oid4vc/auth_server/core/db/session.py
+++ b/oid4vc/auth_server/core/db/session.py
@@ -16,13 +16,20 @@ class DatabaseSessionManager:
"""Async SQLAlchemy session manager."""
def __init__(self, *, search_path: str | None = None) -> None:
- """Constructor."""
+ """Set up with optional schema search_path."""
self._engine: AsyncEngine | None = None
self._sessionmaker: async_sessionmaker[AsyncSession] | None = None
self._search_path = search_path
- def init(self, url: str) -> None:
- """Initialize engine and sessionmaker."""
+ def init(
+ self,
+ url: str,
+ *,
+ pool_size: int = 5,
+ max_overflow: int = 10,
+ pool_recycle: int = 1800,
+ ) -> None:
+ """Create engine and bind sessionmaker."""
connect_args = {}
if self._search_path:
connect_args["server_settings"] = {"search_path": self._search_path}
@@ -30,6 +37,9 @@ def init(self, url: str) -> None:
self._engine = create_async_engine(
url,
pool_pre_ping=True,
+ pool_size=pool_size,
+ max_overflow=max_overflow,
+ pool_recycle=pool_recycle,
connect_args=connect_args or None,
future=True,
)
diff --git a/oid4vc/auth_server/core/observability/observability.py b/oid4vc/auth_server/core/observability/observability.py
index 4e596e401..4e7c9e0c0 100644
--- a/oid4vc/auth_server/core/observability/observability.py
+++ b/oid4vc/auth_server/core/observability/observability.py
@@ -74,7 +74,7 @@ class RequestContextMiddleware(BaseHTTPMiddleware):
"""Bind request_id/method/path to logs and set X-Request-ID."""
def __init__(self, app, header_name: str = "X-Request-ID") -> None:
- """Constructor."""
+ """Attach X-Request-ID propagation."""
super().__init__(app)
self.header_name = header_name
self._logger = logging.getLogger(__name__)
@@ -84,7 +84,7 @@ async def dispatch(
request: Request,
call_next: typing.Callable[[Request], typing.Awaitable[Response]],
):
- """Bind request_id to logs and set X-Request-ID header."""
+ """Bind request context and propagate X-Request-ID."""
request_id = request.headers.get(self.header_name)
if not request_id:
request_id = str(uuid.uuid4())
@@ -143,3 +143,12 @@ def current_request_id(default: str | None = None) -> str | None:
except Exception:
return default
return default
+
+
+def internal_api_headers(token: str) -> dict[str, str]:
+ """Build headers for internal API calls (auth + request-ID propagation)."""
+ headers = {"Authorization": f"Bearer {token}"}
+ rid = current_request_id()
+ if rid:
+ headers["X-Request-ID"] = rid
+ return headers
diff --git a/oid4vc/auth_server/core/repositories/client_repository.py b/oid4vc/auth_server/core/repositories/client_repository.py
index 9ff2dd04e..ecc51dfbe 100644
--- a/oid4vc/auth_server/core/repositories/client_repository.py
+++ b/oid4vc/auth_server/core/repositories/client_repository.py
@@ -10,11 +10,11 @@ class ClientRepository:
"""Data-access for clients."""
def __init__(self, session: AsyncSession) -> None:
- """Constructor."""
+ """Wrap a session."""
self.session = session
async def get_by_client_id(self, client_id: str) -> Client | None:
- """Get client by client_id."""
+ """Lookup by client_id."""
res = await self.session.execute(
select(Client).where(Client.client_id == client_id)
)
diff --git a/oid4vc/auth_server/core/security/client_auth.py b/oid4vc/auth_server/core/security/client_auth.py
deleted file mode 100644
index 9b3d290ee..000000000
--- a/oid4vc/auth_server/core/security/client_auth.py
+++ /dev/null
@@ -1,215 +0,0 @@
-"""Client authentication for issuer APIs."""
-
-import json
-from typing import Any, Mapping
-
-import httpx
-from authlib.jose import JsonWebKey, jwt
-from fastapi import HTTPException, Request, status
-from fastapi.security import HTTPAuthorizationCredentials, HTTPBasicCredentials
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from core.consts import CLIENT_AUTH_METHODS
-from core.consts import ClientAuthMethod as CLIENT_AUTH_METHOD
-from core.crypto.crypto import verify_secret_pbkdf2
-from core.models import Client as AuthClient
-from core.repositories.client_repository import ClientRepository
-from core.security.utils import jwt_header_unverified, jwt_payload_unverified
-from core.utils.logging import get_logger
-
-logger = get_logger(__name__)
-
-
-async def _load_jwks(client) -> dict | None:
- if isinstance(client.jwks, dict):
- return client.jwks
- if client.jwks and isinstance(client.jwks, str):
- try:
- return json.loads(client.jwks)
- except Exception:
- return None
- if client.jwks_uri:
- try:
- async with httpx.AsyncClient(timeout=5.0) as h:
- r = await h.get(client.jwks_uri)
- r.raise_for_status()
- data = r.json()
- return data if isinstance(data, dict) else None
- except Exception:
- return None
- return None
-
-
-def _audiences_for(request: Request) -> list[str]:
- # Full URL without query
- url = str(request.url)
- base = url.split("?", 1)[0]
- return [base]
-
-
-def _validate_jwt_alg(token: str, expected_alg: str):
- """Validate the 'alg' field in the JWT header."""
- header = jwt_header_unverified(token)
- if header.get("alg") != expected_alg:
- raise HTTPException(status_code=401, detail="invalid_alg")
-
-
-def _validate_jwt_claims(decoded: dict[str, Any], request: Request):
- """Validate standard JWT claims."""
- for claim in ("iss", "sub", "aud", "exp", "iat"):
- if claim not in decoded:
- raise HTTPException(status_code=401, detail=f"missing_{claim}")
- aud = decoded.get("aud")
- expected_aud = _audiences_for(request)
- if isinstance(aud, str):
- aud = [aud]
- if not aud or not any(a in expected_aud for a in aud):
- raise HTTPException(status_code=401, detail="invalid_audience")
-
-
-def _decode_and_validate_jwt(
- token: str,
- key_material: Any,
- request: Request,
- expected_alg: str | None = None,
-) -> Mapping[str, Any]:
- """Decode, validate, and return JWT claims using provided key material."""
-
- if expected_alg:
- _validate_jwt_alg(token, expected_alg)
-
- try:
- claims = jwt.decode(token, key_material) # type: ignore[arg-type]
- claims.validate(now=None, leeway=30)
- _validate_jwt_claims(claims, request)
- except Exception as exc:
- raise HTTPException(status_code=401, detail="invalid_client_assertion") from exc
-
- if not isinstance(claims, Mapping):
- raise HTTPException(status_code=401, detail="invalid_client_assertion")
-
- return claims
-
-
-async def _authenticate_private_key_jwt(
- client: AuthClient, token: str, request: Request
-) -> Mapping[str, Any]:
- """Validate private_key_jwt assertions."""
-
- jwks = await _load_jwks(client)
- if not isinstance(jwks, dict) or not jwks.get("keys"):
- raise HTTPException(status_code=401, detail="invalid_client_keys")
-
- keys = JsonWebKey.import_key_set(jwks)
- return _decode_and_validate_jwt(
- token,
- keys,
- request,
- expected_alg=client.client_auth_signing_alg,
- )
-
-
-async def _authenticate_client_secret_jwt(
- client: AuthClient, token: str, request: Request, presented_client_id: str
-) -> Mapping[str, Any]:
- """Validate client_secret_jwt assertions signed with a shared secret."""
-
- secret = client.client_secret or ""
- if not secret:
- raise HTTPException(status_code=401, detail="unauthorized_client")
-
- claims = _decode_and_validate_jwt(
- token,
- secret,
- request,
- expected_alg=client.client_auth_signing_alg,
- )
-
- if str(claims.get("sub")) != str(presented_client_id):
- raise HTTPException(status_code=401, detail="invalid_client")
-
- return claims
-
-
-def _authenticate_client_secret_basic(client: AuthClient, token: str) -> None:
- """Validate client_secret_basic credentials."""
-
- secret_hash = client.client_secret
- if secret_hash and token and verify_secret_pbkdf2(token, secret_hash):
- return
- raise HTTPException(status_code=401, detail="invalid_client")
-
-
-async def base_client_auth(
- db: AsyncSession,
- request: Request,
- basic_creds: HTTPBasicCredentials | None = None,
- credentials: HTTPAuthorizationCredentials | None = None,
-) -> AuthClient:
- """Authenticate client and return the persisted Client model."""
- client_id: str | None = None
- token: str | None = None
-
- scheme = credentials.scheme.lower() if credentials and credentials.scheme else ""
- cred = credentials.credentials if credentials else ""
-
- if scheme == "bearer" and cred:
- token = cred
- try:
- claims = jwt_payload_unverified(token) or {}
- client_id = claims.get("sub")
- except Exception as ex:
- logger.exception("Failed to decode bearer token: %s", ex)
- raise HTTPException(status_code=401, detail="invalid_client_assertion")
- elif basic_creds and basic_creds.username is not None:
- client_id = basic_creds.username
- token = basic_creds.password or ""
- scheme = "basic"
- else:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="unauthorized",
- headers={"WWW-Authenticate": "Bearer, Basic"},
- )
-
- if not client_id or not token:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="unauthorized",
- headers={"WWW-Authenticate": "Bearer, Basic"},
- )
-
- repo = ClientRepository(db)
- client = await repo.get_by_client_id(str(client_id))
- if client is None:
- raise HTTPException(status_code=401, detail="invalid_client")
-
- allowed = (client.client_auth_method or "").lower()
- if allowed not in set(CLIENT_AUTH_METHODS):
- raise HTTPException(status_code=401, detail="unauthorized_client")
-
- if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC and scheme != "basic":
- raise HTTPException(status_code=401, detail="unauthorized_client")
- if (
- allowed
- in {CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT, CLIENT_AUTH_METHOD.CLIENT_SECRET_JWT}
- and scheme != "bearer"
- ):
- raise HTTPException(status_code=401, detail="unauthorized_client")
-
- if allowed == CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT:
- await _authenticate_private_key_jwt(client, token, request)
- request.state.client_id = str(client.client_id)
- return client
-
- if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_JWT:
- await _authenticate_client_secret_jwt(client, token, request, str(client_id))
- request.state.client_id = str(client.client_id)
- return client
-
- if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC:
- _authenticate_client_secret_basic(client, token)
- request.state.client_id = str(client.client_id)
- return client
-
- raise HTTPException(status_code=401, detail="unauthorized_client")
diff --git a/oid4vc/auth_server/core/security/jwks_cache.py b/oid4vc/auth_server/core/security/jwks_cache.py
new file mode 100644
index 000000000..13643f2b9
--- /dev/null
+++ b/oid4vc/auth_server/core/security/jwks_cache.py
@@ -0,0 +1,167 @@
+"""JWKS cache with TTL and refresh-on-kid-miss."""
+
+import asyncio
+import ipaddress
+import socket
+import time
+from collections import OrderedDict
+from urllib.parse import urlparse
+
+import httpx
+from joserfc.jwk import Key, KeySet
+
+from core.utils.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class JWKSCache:
+ """Cache JWKS documents with TTL and refresh-on-kid-miss.
+
+ Two usage patterns:
+ URI mode — ``get_jwks(uri, kid=...)`` fetches/caches by URI.
+ Put mode — ``put(key, jwks, jwks_uri=...)`` pre-loads a keyset,
+ ``get_key(key, kid)`` resolves a single key with refresh-on-miss.
+ """
+
+ def __init__(self, ttl: int = 300, max_size: int = 128):
+ """TTL in seconds, max entries before LRU eviction."""
+ self._cache: OrderedDict[str, tuple[float, KeySet | None, str | None]] = (
+ OrderedDict()
+ )
+ self._ttl = ttl
+ self._max_size = max_size
+
+ @staticmethod
+ def _find_key(key_set: KeySet, kid: str) -> Key | None:
+ """Return key by kid, or None if not found."""
+ try:
+ return key_set.get_by_kid(kid)
+ except Exception:
+ return None
+
+ @staticmethod
+ async def _check_ssrf(uri: str) -> None:
+ """Block URIs that resolve to non-globally-routable addresses."""
+ parsed = urlparse(uri)
+ if parsed.scheme not in ("https", "http"):
+ raise ValueError(f"Unsupported scheme: {parsed.scheme}")
+ hostname = parsed.hostname
+ if not hostname:
+ raise ValueError("Missing hostname")
+
+ # IP literal
+ try:
+ addr = ipaddress.ip_address(hostname)
+ except ValueError:
+ addr = None
+
+ if addr is not None:
+ if not addr.is_global:
+ raise ValueError(f"Blocked address: {hostname}")
+ return
+
+ # Hostname — resolve and check all addresses
+ loop = asyncio.get_running_loop()
+ infos = await loop.run_in_executor(
+ None,
+ lambda: socket.getaddrinfo(
+ hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
+ ),
+ )
+ for info in infos:
+ if not ipaddress.ip_address(info[4][0]).is_global:
+ raise ValueError(f"Blocked address: {info[4][0]} (from {hostname})")
+
+ @staticmethod
+ async def _fetch(uri: str) -> KeySet | None:
+ """Fetch JWKS from URI after SSRF validation. Returns None if empty."""
+ await JWKSCache._check_ssrf(uri)
+ async with httpx.AsyncClient(timeout=10) as client:
+ data = (await client.get(uri)).raise_for_status().json()
+ keys = data.get("keys") if isinstance(data, dict) else None
+ return KeySet.import_key_set(data) if keys else None
+
+ def _touch(self, key: str) -> None:
+ """Move key to end and evict LRU if over capacity."""
+ self._cache.move_to_end(key)
+ while len(self._cache) > self._max_size:
+ self._cache.popitem(last=False)
+
+ # ── Put mode ───────────────────────────────────────────
+
+ def put(self, key: str, jwks: dict | None, *, jwks_uri: str | None = None) -> None:
+ """Store a JWKS document directly (e.g. loaded from DB)."""
+ key_set = KeySet.import_key_set(jwks) if (jwks and jwks.get("keys")) else None
+ self._cache[key] = (time.time(), key_set, jwks_uri)
+ self._touch(key)
+
+ def get_keyset(self, key: str) -> KeySet | None:
+ """Return the full KeySet for a cache key, or None if not cached."""
+ cached = self._cache.get(key)
+ if not cached:
+ return None
+ self._touch(key)
+ _, key_set, _ = cached
+ return key_set
+
+ def is_stale(self, key: str) -> bool:
+ """Return True when the entry is absent or older than the TTL."""
+ cached = self._cache.get(key)
+ if not cached:
+ return True
+ return (time.time() - cached[0]) >= self._ttl
+
+ async def get_key(self, key: str, kid: str) -> Key | None:
+ """Return a single key by kid, refreshing from jwks_uri on miss."""
+ cached = self._cache.get(key)
+ if not cached:
+ return None
+
+ self._touch(key)
+ _, key_set, refresh_uri = cached
+
+ if key_set:
+ found = self._find_key(key_set, kid)
+ if found:
+ return found
+
+ if not refresh_uri:
+ return None
+
+ logger.info("kid %r miss for %s, refreshing", kid, key)
+ try:
+ key_set = await self._fetch(refresh_uri)
+ self._cache[key] = (time.time(), key_set, refresh_uri)
+ self._touch(key)
+ return self._find_key(key_set, kid) if key_set else None
+ except Exception:
+ logger.warning("Failed to refresh JWKS from %s", refresh_uri, exc_info=True)
+ return None
+
+ # ── URI mode ───────────────────────────────────────────
+
+ async def get_jwks(self, uri: str, *, kid: str | None = None) -> KeySet | None:
+ """Return cached KeySet by URI, fetching/refreshing as needed."""
+ now = time.time()
+ cached = self._cache.get(uri)
+
+ if cached:
+ ts, key_set, _ = cached
+ if now - ts < self._ttl:
+ if key_set and (kid is None or self._find_key(key_set, kid)):
+ return key_set
+ logger.info("kid %r miss for %s, refreshing", kid, uri)
+
+ try:
+ key_set = await self._fetch(uri)
+ self._cache[uri] = (time.time(), key_set, uri)
+ self._touch(uri)
+ return key_set
+ except Exception:
+ logger.warning("Failed to fetch JWKS from %s", uri, exc_info=True)
+ return cached[1] if cached else None
+
+ def invalidate(self, key: str) -> None:
+ """Remove a cached entry."""
+ self._cache.pop(key, None)
diff --git a/oid4vc/auth_server/core/security/utils.py b/oid4vc/auth_server/core/security/utils.py
index 945d2e852..d27369e28 100644
--- a/oid4vc/auth_server/core/security/utils.py
+++ b/oid4vc/auth_server/core/security/utils.py
@@ -2,55 +2,33 @@
import base64
import hashlib
-import secrets
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timezone
from typing import Any
-from authlib.jose import JsonWebKey, jwt
+from joserfc import jwt
+from joserfc.jws import extract_compact
+from joserfc.jwk import KeySet
+from core.consts import SUPPORTED_SIGNING_ALGS
from core.utils.json import safe_json_loads
-from tenant.config import settings
def utcnow() -> datetime:
- """UTC now."""
+ """Current time, UTC."""
return datetime.now(timezone.utc)
-def new_refresh_token() -> str:
- """Generate a new refresh token."""
- return secrets.token_urlsafe(settings.TOKEN_BYTES)
-
-
def hash_token(value: str) -> str:
- """Hash a token value."""
+ """SHA-256 of a token, base64url-encoded."""
d = hashlib.sha256(value.encode("utf-8")).digest()
return base64.urlsafe_b64encode(d).decode("ascii").rstrip("=")
-def compute_access_exp(now: datetime | None = None) -> datetime:
- """Compute access token expiry."""
- now = now or utcnow()
- return now + timedelta(seconds=settings.ACCESS_TOKEN_TTL)
-
-
-def compute_refresh_exp(now: datetime | None = None) -> datetime:
- """Compute refresh token expiry."""
- now = now or utcnow()
- return now + timedelta(seconds=settings.REFRESH_TOKEN_TTL)
-
-
-def b64url_decode(data: str) -> bytes:
- """Decode base64url without verification (for JWT header/payload)."""
- pad = "=" * (-len(data) % 4)
- return base64.urlsafe_b64decode(data + pad)
-
-
def jwt_payload_unverified(jwt_str: str) -> dict[str, Any]:
"""Return unverified JWT payload as dict (no signature check)."""
try:
- _, payload, _ = jwt_str.split(".", 2)
- return {} if not payload else safe_json_loads(b64url_decode(payload))
+ obj = extract_compact(jwt_str.encode())
+ return safe_json_loads(obj.payload)
except Exception:
return {}
@@ -58,24 +36,24 @@ def jwt_payload_unverified(jwt_str: str) -> dict[str, Any]:
def jwt_header_unverified(jwt_str: str) -> dict[str, Any]:
"""Return unverified JWT header as dict (no signature check)."""
try:
- header, _, _ = jwt_str.split(".", 2)
- return {} if not header else safe_json_loads(b64url_decode(header))
+ obj = extract_compact(jwt_str.encode())
+ return dict(obj.headers())
except Exception:
return {}
def verify_access_jwt(token: str, jwks: dict, expected_iss: str | None = None):
- """Verify JWT signature & claims using Authlib."""
- # Provide a Key Set; Authlib will pick by 'kid' automatically
- key_set = JsonWebKey.import_key_set(jwks)
+ """Verify JWT signature & claims using joserfc."""
+
+ key_set = KeySet.import_key_set(jwks)
+
+ result = jwt.decode(token, key_set, algorithms=list(SUPPORTED_SIGNING_ALGS))
- # Optionally constrain 'iss' if you want a strict match
- claims_options = {}
+ claims_opts = {}
if expected_iss:
- claims_options["iss"] = {"essential": True, "values": [expected_iss]}
+ claims_opts["iss"] = {"essential": True, "values": [expected_iss]}
- claims = jwt.decode(token, key_set, claims_options=claims_options)
- # exp/nbf/iats checks
- claims.validate(now=datetime.now(timezone.utc))
+ claims_registry = jwt.JWTClaimsRegistry(**claims_opts)
+ claims_registry.validate(result.claims)
- return claims
+ return result.claims
diff --git a/oid4vc/auth_server/core/utils/encoding.py b/oid4vc/auth_server/core/utils/encoding.py
new file mode 100644
index 000000000..4ab1062fe
--- /dev/null
+++ b/oid4vc/auth_server/core/utils/encoding.py
@@ -0,0 +1,13 @@
+"""Base64url (no-pad) encode/decode."""
+
+import base64
+
+
+def b64url_encode(data: bytes) -> str:
+ """Unpadded base64url."""
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
+
+
+def b64url_decode(data: str) -> bytes:
+ """Reverse of b64url_encode, tolerates missing padding."""
+ return base64.urlsafe_b64decode(data + "===")
diff --git a/oid4vc/auth_server/core/utils/logging.py b/oid4vc/auth_server/core/utils/logging.py
index 51864fa17..a115e2a16 100755
--- a/oid4vc/auth_server/core/utils/logging.py
+++ b/oid4vc/auth_server/core/utils/logging.py
@@ -1,81 +1,8 @@
-"""Logging helpers: unified `get_logger` with structlog or stdlib fallback."""
+"""Structlog wrapper."""
-import os
-import logging
-from logging.handlers import RotatingFileHandler
+import structlog
-try:
- import structlog # type: ignore
- HAS_STRUCTLOG = True
-except Exception: # pragma: no cover - optional dependency
- structlog = None # type: ignore
- HAS_STRUCTLOG = False
-
-
-def get_logger(
- name: str,
- log_file: str = "logs/app.log",
- level: int = logging.INFO,
- max_bytes: int = 5 * 1024 * 1024,
- backup_count: int = 3,
- enable_console: bool = True,
- enable_file: bool = False,
-):
- """Return a structlog logger if available, else a configured stdlib logger."""
-
- if HAS_STRUCTLOG:
- # Defer import use to avoid type issues when structlog missing
- return structlog.get_logger(name) # type: ignore[no-any-return]
-
- # Fallback to stdlib logger
- return create_logger(
- name=name,
- log_file=log_file,
- level=level,
- max_bytes=max_bytes,
- backup_count=backup_count,
- enable_console=enable_console,
- enable_file=enable_file,
- )
-
-
-def create_logger(
- name: str,
- log_file: str,
- level: int,
- max_bytes: int,
- backup_count: int,
- enable_console: bool,
- enable_file: bool,
-) -> logging.Logger:
- """Create a logger instance."""
-
- logger = logging.getLogger(name)
- logger.setLevel(level)
- logger.propagate = False
-
- formatter = logging.Formatter(
- "[%(asctime)s] [%(levelname)s] %(name)s: %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
- )
-
- if enable_console and not any(
- isinstance(h, logging.StreamHandler) for h in logger.handlers
- ):
- console_handler = logging.StreamHandler()
- console_handler.setFormatter(formatter)
- logger.addHandler(console_handler)
-
- if enable_file and not any(
- isinstance(h, RotatingFileHandler) for h in logger.handlers
- ):
- if log_dir := os.path.dirname(log_file):
- os.makedirs(log_dir, exist_ok=True)
- handler = RotatingFileHandler(
- log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
- )
- handler.setFormatter(formatter)
- logger.addHandler(handler)
-
- return logger
+def get_logger(name: str):
+ """Bound logger."""
+ return structlog.get_logger(name)
diff --git a/oid4vc/auth_server/core/utils/retry.py b/oid4vc/auth_server/core/utils/retry.py
index 2877bf7e8..350d6af2f 100644
--- a/oid4vc/auth_server/core/utils/retry.py
+++ b/oid4vc/auth_server/core/utils/retry.py
@@ -8,6 +8,8 @@
from functools import wraps
from typing import Any, Callable, Iterable, Optional, Type
+from core.utils.logging import get_logger
+
def with_retries(
*,
@@ -21,7 +23,7 @@ def with_retries(
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Retry a callable on specified exceptions with backoff."""
- log = logger or logging.getLogger(__name__)
+ log = logger or get_logger(__name__)
retry_on_tuple = tuple(retry_on)
def _next_delay(current: float) -> float:
diff --git a/oid4vc/auth_server/docs/auth-server-design.md b/oid4vc/auth_server/docs/auth-server-design.md
index d0fa1b9e7..425bc8aaf 100644
--- a/oid4vc/auth_server/docs/auth-server-design.md
+++ b/oid4vc/auth_server/docs/auth-server-design.md
@@ -13,7 +13,7 @@ This system supports **secure, standards-aligned issuance of Verifiable Credenti
- 🔐 **Pre-Authorized Code Flow** – Enables issuance without user login
- 🛡️ **DPoP-bound Access Tokens** – Proof-of-possession enforcement ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449.html))
- 📄 **Authorization Details** – Credential-specific authorization rules ([RFC 9396](https://www.rfc-editor.org/rfc/rfc9396.html))
-- 🧾 **Attestation PoP** – Verified by the Authorization Server using attestation PoP JWT presented by the wallet
+- 🧾 **Attestation PoP** – Wallet Provider signs an attestation JWT (`kid`-based); Wallet Instance signs an attestation PoP JWT; the Authorization Server verifies both against a managed allow list of trusted providers
- 🔁 **Refresh Token Rotation** – Mitigates token reuse and supports long-lived sessions
- 🧠 **Token Introspection** – Fine-grained validation with embedded credential metadata
- 🌐 **Metadata Discovery** – Standards-based wallet interoperability via `.well-known` endpoints
@@ -84,7 +84,7 @@ The system supports multi-tenancy for data isolation in OID4VCI workflows. The C
| 3 | Refresh Token issued | Auth Server | Every access token comes with a refresh token. |
| 4 | Refresh Token rotation | Auth Server | One-time-use refresh tokens; replaced with each request. |
| 5 | Decoupled authorization | Auth Server + Config | Tokens validated externally or by config-swappable AS. |
-| 6 | Attestation verification | Authorization Server | Verify `client_attestation` PoP JWT at `/token` |
+| 6 | Attestation verification | Authorization Server | Verify attestation JWT + attestation PoP JWT at `/token` |
| 7 | `/nonce` endpoint | Credential Issuer | Required by OID4VCI; prevents nonce reuse. |
---
@@ -153,10 +153,10 @@ sequenceDiagram
CredentialIssuer-->>Wallet: credential_offer_uri
Wallet->>AuthorizationServer: POST /token
- Note over Wallet, AuthorizationServer: Includes:
- pre-authorized_code
- DPoP JWT
- client_attestation PoP JWT
+ Note over Wallet, AuthorizationServer: Includes:
- pre-authorized_code
- Header: OAuth-Client-Attestation
- Header: OAuth-Client-Attestation-PoP
alt Valid `/token` request
- AuthorizationServer->>AuthorizationServer: Validate DPoP + Attestation PoP
- AuthorizationServer->>DB: Store access_token (with cnf.jkt, amr, attestation metadata) + refresh_token
+ AuthorizationServer->>AuthorizationServer: Validate Attestation (kid lookup in allow list)
+ AuthorizationServer->>DB: Store access_token (with amr, attestation metadata) + refresh_token
AuthorizationServer-->>Wallet: access_token, refresh_token
else Invalid
AuthorizationServer-->>Wallet: HTTP 400/401
@@ -166,9 +166,9 @@ sequenceDiagram
CredentialIssuer-->>Wallet: nonce
Wallet->>CredentialIssuer: POST /credential
- Note over Wallet, CredentialIssuer: Includes:
- access_token
- DPoP JWT
- credential proof (with /nonce)
+ Note over Wallet, CredentialIssuer: Includes:
- access_token
- credential proof (with /nonce)
CredentialIssuer->>AuthorizationServer: POST /introspect
- AuthorizationServer->>DB: Validate token, return cnf.jkt, amr, attestation metadata
+ AuthorizationServer->>DB: Validate token, return amr, attestation metadata
CredentialIssuer-->>Wallet: Verifiable Credential
```
@@ -200,63 +200,124 @@ sequenceDiagram
| Component | Validates |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
-| Authorization Server | Pre-auth code; **DPoP JWT** (proof-of-possession); **Attestation PoP JWT** per policy; refresh token rotation (validated for `used=false`) |
-| Credential Issuer | Introspection (active token, realm match); **DPoP match (`cnf.jkt`)**; Nonce proof |
+| Authorization Server | Pre-auth code; **Attestation JWT** (verified via `kid` + allow list); **Attestation PoP JWT** (wallet instance proof); refresh token rotation (validated for `used=false`) |
+| Credential Issuer | Introspection (active token, realm match); Nonce proof |
---
### 📦 Notes
-- Attestation PoP is verified by the Authorization Server at `/token`.
-- DPoP `jkt` thumbprint is stored in `access_token.cnf_jkt`, and enforced by both the AS (at `/token`) and Issuer (at `/credential`).
-- The Issuer uses `/introspect` to validate the access token and retrieve embedded claims (e.g., `cnf.jkt`, `amr`, and `attestation` metadata), then performs its own DPoP check.
+- Attestation JWT is verified by the Authorization Server at `/token` using `kid` + `iss` lookup against the allow list of trusted Wallet Providers.
+- The attestation `cnf.jwk` contains the wallet instance's public key. The `OAuth-Client-Attestation-PoP` JWT proves the wallet holds the corresponding private key.
+- The Issuer uses `/introspect` to validate the access token and retrieve embedded claims (e.g., `amr` and `attestation` metadata).
- Wallets **do not** send attestation to `/credential`; it’s only relevant at `/token`.
---
## 🧾 Attestation PoP
-**Purpose:** Provide an additional verifiable PoP signal at `/token`, without any interaction with external attestation providers.
+**Purpose:** Provide an additional verifiable PoP signal at `/token`, ensuring the client is a legitimate wallet attested by a trusted Wallet Provider.
### Flow
-1. **Client Attestation PoP JWT (policy-driven)**
+1. **Attestation JWT (signed by Wallet Provider)**
- - Issued by the wallet provider and signed.
- - Contains claims the Auth Server can verify (e.g., `iss`, `sub`, `iat`, `exp`, a key-binding claim).
+ - Wallet Provider signs the JWT with their private key.
+ - Header: `typ` (`oauth-client-attestation+jwt`), `alg`, `kid`.
+ - Payload: `iss` (Wallet Provider identifier), `sub` (client_id — see note below), `cnf.jwk`, `iat`, `exp`.
+ - The `cnf.jwk` contains the wallet instance's public key, binding the attestation to the wallet instance.
+ - Sent via the `OAuth-Client-Attestation` HTTP header.
+ - **`sub` claim semantics (OID4VCI §15.4.4):** The `sub` value SHOULD identify the **wallet type** (e.g., `https://wallet.example.org`), NOT a unique per-instance identifier. All wallet instances of the same Wallet Provider share the same `sub` value to prevent cross-Authorization-Server correlation of individual users.
-2. **Verification (Authorization Server)**
+2. **Attestation PoP JWT (signed by Wallet Instance)**
- - Validate signature and required claims.
- - Optionally bind to the same key used for DPoP by matching a thumbprint (`jkt`) claim.
- - Apply trust policy: `auto_trust`, `allow_list`, or `deny_list`.
+ - Wallet Instance signs a proof-of-possession JWT with the private key corresponding to the `cnf.jwk` in the attestation.
+ - Header: `typ` (`oauth-client-attestation-pop+jwt`), `alg`.
+ - Payload: `iss` (client_id — MUST match `sub` of the attestation JWT), `aud` (Authorization Server issuer identifier), `jti` (unique identifier for replay detection), `iat`, and optionally `challenge` (server-provided).
+ - Proves the wallet holds the attested key.
+ - Sent via the `OAuth-Client-Attestation-PoP` HTTP header.
-3. **Outcome**
- - If policy requires attestation PoP and verification fails → `invalid_attestation`.
- - On success, record outcome in `ACCESS_TOKEN.metadata` and add `"att-pop"` to `amr`.
+3. **Allow List (Trusted Wallet Providers)**
+
+ - Admin registers trusted providers via API: `iss` + JWKS document (inline `jwks`) or `jwks_uri`.
+ - Each provider row stores the full keyset; individual keys are resolved by `kid` at verification time.
+ - Provider keys are obtained out-of-band (e.g., published on wallet provider's website).
+ - If `jwks_uri` is provided, keys are fetched on-demand with SSRF protection and LRU caching.
+ - This is a closed ecosystem — no DID resolution or discoverability needed.
+
+4. **Verification (Authorization Server)**
+
+ - Extract `kid` from header and `iss` from payload.
+ - Look up the provider's public key by `iss` + `kid` in the allow list.
+ - Verify attestation JWT signature using the provider's public key.
+ - Validate claims (`iss`, `sub`, `iat`, `exp`) and time validity (± clock skew).
+ - Extract `cnf.jwk` and verify the `OAuth-Client-Attestation-PoP` JWT signature against it (proves wallet instance holds the attested key).
+
+5. **Outcome**
+ - If `iss` + `kid` not found → `invalid_client_attestation` (untrusted provider).
+ - If signature or claims invalid → `invalid_client_attestation`.
+ - On success:
+ - Compute `cnf_jkt = thumbprint(cnf.jwk)` (RFC 7638) and store on `ACCESS_TOKEN.cnf_jkt`.
+ - Record attestation metadata (iss, kid, sub, cnf_jkt, pop_jti, iat, exp) in `ACCESS_TOKEN.metadata`.
+ - Add `"att-pop"` to `amr`.
+ - On refresh without new attestation headers, the previous token's attestation metadata and `cnf_jkt` are carried forward to the new access token.
### Example `/token` Request (with Attestation PoP)
```http
POST /token
Content-Type: application/x-www-form-urlencoded
-DPoP:
+OAuth-Client-Attestation:
+OAuth-Client-Attestation-PoP:
grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code&
-pre-authorized_code=abc123&
-client_attestation=eyJhbGciOiJSUzI1NiIs...
+pre-authorized_code=abc123
```
-### Trust Policy Modes
+```mermaid
+sequenceDiagram
+ participant WalletProvider as Wallet Provider
+ participant Wallet
+ participant AuthServer as Authorization Server
+ participant Issuer as Credential Issuer
+ participant DB
+
+ Note over WalletProvider,AuthServer: 1. Admin: Register Trusted Wallet Providers (prerequisite)
+
+ WalletProvider-->>AuthServer: Out-of-band: publish public key(s)
+ Note over AuthServer: Admin registers via API:
iss + JWKS (or jwks_uri)
stored in allow list table
+
+ Note over WalletProvider,Wallet: 2. Wallet Attestation Provisioning
-- **auto_trust** – Accept any syntactically valid, verifiable PoP JWT.
-- **allow_list** – Accept only if (`sub`, optional `jkt`) pair appears in a configured list.
-- **deny_list** – Explicitly reject known-bad `sub` (and/or `jkt`).
+ Wallet->>WalletProvider: Request attestation (app identity + device proof)
+ WalletProvider->>WalletProvider: Verify integrity, sign attestation JWT (kid: "11")
+ WalletProvider-->>Wallet: Attestation JWT
+ Note over Wallet: Wallet caches attestation JWT
(valid until exp, reusable across requests)
-**Errors (Auth Server):**
+ Note over Wallet,AuthServer: 3. Token Request with Attestation
-- `invalid_attestation` – Missing/malformed/expired/failed verification.
-- `invalid_request` – Violates trust policy.
+ Wallet->>Wallet: Generate Attestation PoP JWT (signed with cnf.jwk key)
+ Wallet->>AuthServer: POST /token + OAuth-Client-Attestation + OAuth-Client-Attestation-PoP
+
+ AuthServer->>DB: Look up provider key by iss + kid
+ AuthServer->>AuthServer: Validate attestation + PoP
+
+ alt Attestation valid
+ AuthServer->>DB: Store access_token with attestation metadata + amr: ["att-pop"]
+ AuthServer-->>Wallet: 200 access_token, refresh_token
+ else Invalid or untrusted
+ AuthServer-->>Wallet: 400 invalid_client_attestation (untrusted provider)
+ end
+
+ Note over Wallet,AuthServer: Credential Request & Introspection
+
+ Wallet->>Issuer: POST /credential (Bearer access_token)
+ Issuer->>AuthServer: POST /introspect (token)
+ AuthServer->>DB: Look up access_token + metadata
+ AuthServer-->>Issuer: active: true, amr, attestation metadata
+ Issuer->>Issuer: Check attestation metadata from introspection (optional policy)
+ Issuer-->>Wallet: Verifiable Credential
+```
---
@@ -264,13 +325,64 @@ client_attestation=eyJhbGciOiJSUzI1NiIs...
### ✅ Overview
-DPoP ensures that access tokens are bound to a client’s private key, preventing unauthorized use of stolen tokens (RFC 9449).
+DPoP ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449.html)) binds access tokens to a client's asymmetric key pair, preventing unauthorized use of stolen tokens. Even if an attacker exfiltrates an access token, they cannot use it without the corresponding private key.
### ✅ Support
-- **Required for `/token` and `/credential`**.
-- Access tokens include a `cnf.jkt` claim (JWK Thumbprint) binding the token to the client’s DPoP key.
-- Replay protection via `jti` on DPoP JWTs.
+- **Required at `/token`** (Authorization Server) and **`/credential`** (Credential Issuer).
+- Access tokens are issued with `"token_type": "DPoP"` (not `"Bearer"`).
+- Access tokens include a `cnf.jkt` claim (JWK Thumbprint per [RFC 7638](https://www.rfc-editor.org/rfc/rfc7638.html)) binding the token to the client's DPoP key.
+- Replay protection via `jti` claim on DPoP proof JWTs, tracked in `JTI_SEEN`.
+- Server-provided nonce support via `DPoP-Nonce` response header and `use_dpop_nonce` error.
+
+### 🔑 DPoP Key vs. Attestation Key
+
+DPoP and Client Attestation are **independent mechanisms** that serve different purposes:
+
+| Mechanism | Purpose | Key |
+|-----------|---------|-----|
+| Client Attestation | Proves the client is a legitimate wallet instance | `cnf.jwk` in attestation JWT |
+| DPoP | Binds the access token to the client, preventing token theft | DPoP key pair (in `DPoP` header `jwk`) |
+
+The wallet MAY use the **same key** for both (the attestation `cnf.jwk` private key also signs DPoP proofs), or it MAY use separate keys. The Authorization Server does not enforce a relationship between them — they are validated independently.
+
+### 🧬 DPoP Proof JWT Structure
+
+Each DPoP proof is a JWT with:
+
+**Header:**
+```json
+{
+ "typ": "dpop+jwt",
+ "alg": "ES256",
+ "jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
+}
+```
+
+**Payload (at `/token`):**
+```json
+{
+ "jti": "unique-id-per-request",
+ "htm": "POST",
+ "htu": "https://auth.example.com/tenants/{tenant-id}/token",
+ "iat": 1721028000,
+ "nonce": "server-provided-nonce"
+}
+```
+
+**Payload (at `/credential` — resource server):**
+```json
+{
+ "jti": "unique-id-per-request",
+ "htm": "POST",
+ "htu": "https://issuer.example.com/credential",
+ "iat": 1721028000,
+ "ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
+ "nonce": "server-provided-nonce"
+}
+```
+
+The `ath` claim is the base64url-encoded SHA-256 hash of the access token — required when the DPoP proof accompanies a token at a resource server (RFC 9449 §4.2).
### 🧬 Token Binding Flow with DPoP
@@ -279,19 +391,87 @@ sequenceDiagram
participant Wallet
participant AuthServer as Authorization Server
participant Issuer as Credential Issuer
- Wallet->>AuthServer: POST /token + DPoP JWT + Attestation PoP
- AuthServer-->>Wallet: access_token (with cnf.jkt, amr) + refresh_token
- Wallet->>Issuer: POST /credential + DPoP JWT
+
+ Note over Wallet: Generate DPoP key pair (once per session)
+
+ Wallet->>AuthServer: POST /token + DPoP header (htm=POST, htu=/token)
+ Note over AuthServer: Validate DPoP proof:
typ, alg, jwk, htm, htu, jti, iat, nonce
+ AuthServer->>AuthServer: Derive cnf.jkt = thumbprint(proof.jwk)
+ AuthServer-->>Wallet: token_type: "DPoP", access_token (cnf.jkt bound), refresh_token
+
+ Note over Wallet: Generate fresh DPoP proof for /credential
(same key, new jti, ath = hash(access_token))
+
+ Wallet->>Issuer: POST /credential + DPoP header (htm=POST, htu=/credential, ath)
+ Issuer->>AuthServer: POST /introspect (token)
+ AuthServer-->>Issuer: active: true, cnf.jkt: "..."
+ Note over Issuer: Validate DPoP proof + verify
thumbprint(proof.jwk) == cnf.jkt from introspection
Issuer-->>Wallet: Verifiable Credential
```
-### 🛠️ Implementation Steps
+### 🔄 Nonce Flow (Optional Server-Enforced)
+
+The Authorization Server MAY require a nonce in DPoP proofs to limit proof reuse windows:
+
+```mermaid
+sequenceDiagram
+ participant Wallet
+ participant AuthServer as Authorization Server
+
+ Wallet->>AuthServer: POST /token + DPoP (no nonce)
+ AuthServer-->>Wallet: 400 use_dpop_nonce + DPoP-Nonce: "server-nonce-1"
+ Wallet->>AuthServer: POST /token + DPoP (nonce: "server-nonce-1")
+ AuthServer-->>Wallet: 200 access_token + DPoP-Nonce: "server-nonce-2"
+ Note over Wallet: Cache nonce for next request to this server
+```
+
+### 🛠️ Authorization Server Validation (RFC 9449 §4.3)
+
+When the `DPoP` header is present at `/token`, the Authorization Server MUST:
+
+1. Verify exactly one `DPoP` header is present.
+2. Decode and validate the JWT structure.
+3. Verify `typ` header is `dpop+jwt`.
+4. Verify `alg` is a supported asymmetric algorithm (e.g., `ES256`) and not `none`.
+5. Verify the JWT signature using the public key in the `jwk` header.
+6. Verify `jwk` does not contain a private key.
+7. Verify `htm` matches the HTTP method (`POST`).
+8. Verify `htu` matches the request URI (scheme + authority + path, no query/fragment).
+9. If nonce is required, verify the `nonce` claim matches the server-provided value.
+10. Verify `iat` is within an acceptable time window.
+11. Verify `jti` has not been seen before (replay prevention via `JTI_SEEN` table).
+12. Derive `cnf.jkt` = base64url(SHA-256(canonicalized JWK)) and store on the access token.
+
+### 🛠️ Credential Issuer Validation
+
+When the wallet presents a DPoP-bound token at `/credential`, the Credential Issuer MUST:
+
+1. Validate the DPoP proof (steps 1–11 above, with `htu` = credential endpoint URL).
+2. Verify the `ath` claim equals base64url(SHA-256(access_token)).
+3. Call `/introspect` to get the token's `cnf.jkt`.
+4. Verify `thumbprint(proof.jwk) == cnf.jkt` (key binding check).
+
+### 📦 Storage & Metadata
+
+- `ACCESS_TOKEN.cnf_jkt`: Stores the DPoP key thumbprint bound to the token.
+- `JTI_SEEN.metadata`: For DPoP proofs, stores `{"htm": "POST", "htu": "...", "cnf_jkt": "..."}`.
+- DPoP nonces: Stored server-side with time-based rotation (configurable window).
+
+### 🔒 Error Responses
-1. Client generates DPoP key pair (public JWK appears in `DPoP` header).
-2. Client sends DPoP header with `/token`.
-3. Auth Server validates signature, `htu`/`htm`, and `jti` uniqueness; derives `cnf.jkt`.
-4. Issued access token contains `cnf.jkt`.
-5. Issuer enforces DPoP on `/credential` by matching request DPoP proof to `cnf.jkt`.
+| Error | HTTP Status | When |
+|-------|-------------|------|
+| `invalid_dpop_proof` | 400 | Malformed proof, bad signature, wrong `htm`/`htu`, expired `iat`, unknown `alg` |
+| `use_dpop_nonce` | 400 | Server requires nonce but none provided, or nonce is stale (includes `DPoP-Nonce` header) |
+| `invalid_token` | 401 | DPoP key thumbprint doesn't match `cnf.jkt` on the token (at resource server) |
+
+### 📝 `.well-known` Discovery
+
+The Authorization Server metadata SHOULD include:
+```json
+{
+ "dpop_signing_alg_values_supported": ["ES256"]
+}
+```
---
@@ -324,13 +504,13 @@ sequenceDiagram
Wallet->>CredentialIssuer: GET /.well-known/openid-credential-issuer
CredentialIssuer-->>Wallet: Metadata (token/credential endpoints)
- Wallet->>AuthServer: POST /token (pre-auth-code + DPoP + attestation PoP)
- AuthServer->>DB: Validate + bind DPoP + attestation policy
+ Wallet->>AuthServer: POST /token (pre-auth-code + attestation PoP headers)
+ AuthServer->>DB: Validate + verify attestation (kid lookup)
AuthServer-->>Wallet: access_token + refresh_token
- Wallet->>CredentialIssuer: POST /credential + DPoP
+ Wallet->>CredentialIssuer: POST /credential
CredentialIssuer->>AuthServer: POST /introspect
- AuthServer-->>CredentialIssuer: token is active + authorization_details + cnf.jkt + amr/attestation
+ AuthServer-->>CredentialIssuer: token is active + authorization_details + amr/attestation
CredentialIssuer-->>Wallet: Verifiable Credential
```
@@ -377,9 +557,9 @@ sequenceDiagram
| Endpoint | Method | Auth | Description |
| ----------------------------------- | ------ | ----------------------------------------- | ------------------------------------ |
-| `/token` | POST | DPoP + optional attestation PoP | Token exchange (pre-auth or refresh) |
-| `/introspect` | POST | private_key_jwt \| client_secret_basic \| shared_bearer | Token validation + attestation |
-| `/grants/pre-authorized-code` | POST | private_key_jwt \| client_secret_basic \| shared_bearer | Issue a pre-authorized code grant. |
+| `/token` | POST | Optional attestation PoP (via headers) | Token exchange (pre-auth or refresh) |
+| `/introspect` | POST | private_key_jwt \| client_secret_basic | Token validation + attestation |
+| `/grants/pre-authorized-code` | POST | private_key_jwt \| client_secret_basic | Issue a pre-authorized code grant. |
| `/.well-known/openid-configuration` | GET | None | Auth Server metadata discovery |
### Credential Issuer
@@ -387,7 +567,7 @@ sequenceDiagram
| Endpoint | Method | Auth | Description |
| --------------------------------------- | ------ | ------------- | -------------------------------- |
| `/credential_offer` | GET | None | Offer URI with pre-auth code |
-| `/credential` | POST | Bearer + DPoP | Request VC |
+| `/credential` | POST | Bearer | Request VC |
| `/nonce` | GET | None | Nonce for credential proof |
| `/.well-known/openid-credential-issuer` | GET | None | OID4VC Issuer metadata discovery |
@@ -395,19 +575,19 @@ sequenceDiagram
### 🔐 `POST /token`
-Exchanges a pre-authorized code or refresh token for an access token (and new refresh token for rotation). If policy requires attestation PoP, the request must include `client_attestation`.
+Exchanges a pre-authorized code or refresh token for an access token (and new refresh token for rotation). If policy requires attestation PoP, the request must include `OAuth-Client-Attestation` and `OAuth-Client-Attestation-PoP` headers.
**Request**
```http
POST /token
Content-Type: application/x-www-form-urlencoded
-DPoP:
+OAuth-Client-Attestation:
+OAuth-Client-Attestation-PoP:
grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code&
pre-authorized_code=abc123&
-tx_code=1234&
-client_attestation=eyJhbGciOiJSUzI1NiIs...
+tx_code=1234
```
**Response**
@@ -426,14 +606,13 @@ client_attestation=eyJhbGciOiJSUzI1NiIs...
"types": ["VerifiableCredential", "OntarioBusinessCard"]
}
],
- "cnf": { "jkt": "base64url-encoded-thumbprint" },
- "amr": ["dpop", "att-pop"]
+ "amr": ["att-pop"]
}
```
**Errors**
-- HTTP 400 `invalid_request`: Invalid pre-authorized code, DPoP JWT, malformed attestation PoP.
+- HTTP 400 `invalid_request`: Invalid pre-authorized code, malformed attestation PoP.
- HTTP 401 `invalid_token`: Invalid or revoked refresh token.
- HTTP 401 `invalid_attestation`: Attestation PoP required by policy but missing or failed verification.
@@ -475,15 +654,15 @@ token_type_hint=access_token
"cnf": { "jkt": "QmFzZTY0ZW5jb2RlZFRodW1icHJpbnQ=" },
"iss": "https://auth.example.com",
"realm": "tenant1",
- "amr": ["dpop", "att-pop"],
+ "amr": ["att-pop"],
"attestation": {
"present": true,
"verified": true,
- "policy": "allow_list",
- "decision": "trusted",
- "jkt": "QmFzZTY0ZW5jb2RlZFRodW1icHJpbnQ=",
- "sub": "wallet.app.id:123",
- "hash": "sha256:2f1c...ab",
+ "iss": "https://wallet-provider.example.com",
+ "kid": "11",
+ "sub": "https://wallet.example.org",
+ "cnf_jkt": "QmFzZTY0ZW5jb2RlZFRodW1icHJpbnQ=",
+ "pop_jti": "unique-pop-jti-value",
"iat": 1721028000,
"exp": 1721031600
}
@@ -506,7 +685,6 @@ Request a credential using an access token. (No attestation here; enforced at `/
```http
POST /credential
Authorization: Bearer eyJhbGciOi...
-DPoP:
Content-Type: application/json
{
@@ -527,7 +705,7 @@ Content-Type: application/json
**Errors**
-- HTTP 400 `invalid_request`: Invalid access token, DPoP JWT, or nonce proof.
+- HTTP 400 `invalid_request`: Invalid access token or nonce proof.
---
@@ -641,7 +819,7 @@ GET /.well-known/openid-configuration
{
"issuer": "https://auth.example.com",
"token_endpoint": "https://auth.example.com/token",
- "token_endpoint_auth_methods_supported": ["none"],
+ "token_endpoint_auth_methods_supported": ["private_key_jwt", "client_secret_basic"],
"grant_types_supported": [
"urn:ietf:params:oauth:grant-type:pre-authorized_code",
"refresh_token"
@@ -655,9 +833,9 @@ GET /.well-known/openid-configuration
### 🔍 Error Handling
- Standard OAuth2 errors: `invalid_token`, `expired_token`, `invalid_grant`, etc.
-- DPoP errors: `invalid_dpop_proof`, `replay_detected`.
+- DPoP errors (planned): `invalid_dpop_proof`, `replay_detected`.
- Nonce errors: `invalid_request` for invalid/expired nonces; `too_many_requests` for exceeding rate limits.
-- Attestation errors: `invalid_attestation` or `invalid_request` if `client_attestation` is missing/invalid or fails policy.
+- Attestation errors: `invalid_attestation` if attestation headers are missing/invalid, signature verification fails, Attestation-PoP verification fails, or `iss` + `kid` not found in the allow list.
---
@@ -676,7 +854,6 @@ erDiagram
SUBJECT ||--o{ PRE_AUTH_CODE : issued
SUBJECT ||--o{ ACCESS_TOKEN : manages
SUBJECT ||--o{ REFRESH_TOKEN : manages
- SUBJECT ||--o{ DPOP_JTI : manages
CLIENT
SUBJECT {
@@ -712,7 +889,7 @@ erDiagram
INT id PK
INT subject_id FK
INT access_token_id FK
- TEXT token UK
+ TEXT token_hash UK "SHA-256"
TIMESTAMPTZ issued_at
TIMESTAMPTZ expires_at
BOOLEAN used
@@ -720,17 +897,6 @@ erDiagram
JSONB metadata
}
- DPOP_JTI {
- INT id PK
- INT subject_id FK
- TEXT jti UK
- TEXT htm
- TEXT htu
- TEXT cnf_jkt
- TIMESTAMPTZ issued_at
- TIMESTAMPTZ expires_at
- }
-
NONCE {
INT id PK
TEXT value UK
@@ -750,6 +916,23 @@ erDiagram
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
+
+ WALLET_PROVIDER {
+ INT id PK
+ TEXT iss "UNIQUE INDEX"
+ JSONB jwks "Inline JWKS document (nullable)"
+ TEXT jwks_uri "Remote JWKS endpoint (nullable)"
+ TEXT name
+ BOOLEAN active
+ TIMESTAMPTZ created_at
+ TIMESTAMPTZ updated_at
+ }
+
+ JTI_SEEN {
+ TEXT jti PK
+ TIMESTAMPTZ expires_at
+ JSONB metadata "htm, htu, cnf_jkt for DPoP"
+ }
```
**Note**: ACCESS_TOKEN.metadata may include amr and attestation outcome.
@@ -764,14 +947,21 @@ erDiagram
- **Token Format**: Access tokens are JWTs with claims (`aud`, `exp`, `cnf`, `amr`, etc.).
- **DPoP Key Lifecycle**: Clients manage DPoP key pairs; rotate if compromised.
- **CORS**: Restrict cross-origin requests where applicable.
+- **Refresh Token Hashing**: Refresh tokens are stored as SHA-256 hashes; raw values are never persisted.
+- **JTI Replay Prevention**: Client assertion, attestation PoP, and DPoP JTIs are tracked in `JTI_SEEN` (PostgreSQL INSERT ON CONFLICT). Entries persist until the JWT's `exp` claim for client assertions, or a fixed window for attestation PoPs and DPoP proofs. DPoP entries store `htm`, `htu`, and `cnf_jkt` in the `metadata` column.
+- **SSRF Protection**: `jwks_uri` fetches validate the scheme, resolve DNS hostnames, and reject private/loopback/link-local/reserved IP addresses before making HTTP requests.
+- **LRU-bounded Caches**: Tenant context cache, engine pools, and JWKS caches use `OrderedDict`-based LRU eviction to prevent unbounded memory growth.
+- **Migration Safety**: Downgrade operations via `POST /admin/tenants/{uid}/migrations` require explicit `confirm: true` in the request body.
+- **Proxy Trust**: `PROXY_TRUSTED_HOSTS` controls which reverse proxies are trusted to set `X-Forwarded-For` headers. Default: `127.0.0.1`. Set to the specific LB/proxy IP in production; never use `"*"`.
+- **Algorithm Enforcement**: JWT verification restricts accepted algorithms to the client's configured `client_auth_signing_alg` (or `ES256` default), preventing algorithm confusion attacks.
## 📘 Terminology
- **JWT (JSON Web Token)**: A compact, signed token format (RFC 7519).
- **JWK (JSON Web Key)**: JSON representation of a cryptographic key (RFC 7517).
-- **JKT (JWK Thumbprint)**: Base64url-encoded hash of a JWK (RFC 7638). Used in DPoP and, optionally, in attestation PoP binding.
-- **JTI (JWT ID)**: Unique ID for a JWT, used for replay prevention in DPoP flows.
-- **DPoP**: Mechanism to bind access tokens to a client’s key (RFC 9449).
-- **Attestation PoP**: JWT from the wallet, verified by the Authorization Server to assert client/app properties under a trust policy.
+- **JKT (JWK Thumbprint)**: Base64url-encoded hash of a JWK (RFC 7638). Used in DPoP and in attestation `cnf.jwk` binding.
+- **JTI (JWT ID)**: Unique ID for a JWT, used for replay prevention.
+- **DPoP**: Mechanism to bind access tokens to a client's key (RFC 9449). Independent from attestation.
+- **Attestation PoP**: Two-part client attestation mechanism. The `OAuth-Client-Attestation` header carries a JWT signed by a trusted Wallet Provider (identified by `iss` + `kid`), verified against a managed allow list. The `OAuth-Client-Attestation-PoP` header carries a JWT signed by the Wallet Instance, proving possession of the key in `cnf.jwk`.
- **Pre-authorized Code**: One-time-use code issued by the AS to enable issuance without user login.
- **Realm**: Logical identifier mapping a token to a specific tenant.
diff --git a/oid4vc/auth_server/docs/tenant-admin.md b/oid4vc/auth_server/docs/tenant-admin.md
index fdda124ea..8cce8289f 100644
--- a/oid4vc/auth_server/docs/tenant-admin.md
+++ b/oid4vc/auth_server/docs/tenant-admin.md
@@ -66,6 +66,8 @@ sequenceDiagram
- DELETE `/admin/tenants/{uid}/clients/{client_id}`
- Migrations
- POST `/admin/tenants/{uid}/migrations`
+ - Body: `{ "action": "upgrade"|"downgrade", "rev": "", "confirm": true }`
+ - Downgrade requires `confirm: true` (returns 400 otherwise)
### 🔑 Key Management
@@ -107,11 +109,10 @@ graph
PRE_AUTH_CODE[PRE_AUTH_CODE]
ACCESS_TOKEN[ACCESS_TOKEN]
REFRESH_TOKEN[REFRESH_TOKEN]
- DPOP_JTI[DPOP_JTI]
+ JTI_SEEN[JTI_SEEN]
SUBJECT -->|has| PRE_AUTH_CODE
SUBJECT -->|has| ACCESS_TOKEN
SUBJECT -->|has| REFRESH_TOKEN
- SUBJECT -->|has| DPOP_JTI
TENANT_ANCHOR((( )))
end
diff --git a/oid4vc/auth_server/poetry.lock b/oid4vc/auth_server/poetry.lock
index f59e920c9..4e45c990c 100644
--- a/oid4vc/auth_server/poetry.lock
+++ b/oid4vc/auth_server/poetry.lock
@@ -51,6 +51,35 @@ sniffio = ">=1.1"
[package.extras]
trio = ["trio (>=0.31.0)"]
+[[package]]
+name = "appnope"
+version = "0.1.4"
+description = "Disable App Nap on macOS >= 10.9"
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+markers = "platform_system == \"Darwin\""
+files = [
+ {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"},
+ {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"},
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.2"
+description = "Annotate AST trees with source code positions"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933"},
+ {file = "asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2"},
+]
+
+[package.extras]
+astroid = ["astroid (>=2,<5)"]
+test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"]
+
[[package]]
name = "asyncpg"
version = "0.30.0"
@@ -117,18 +146,19 @@ test = ["distro (>=1.9.0,<1.10.0)", "flake8 (>=6.1,<7.0)", "flake8-pyi (>=24.1.0
[[package]]
name = "authlib"
-version = "1.6.12"
+version = "1.7.2"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab"},
- {file = "authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd"},
+ {file = "authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f"},
+ {file = "authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231"},
]
[package.dependencies]
cryptography = "*"
+joserfc = ">=1.6.0"
[[package]]
name = "certifi"
@@ -148,8 +178,7 @@ version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.9"
-groups = ["main"]
-markers = "platform_python_implementation != \"PyPy\""
+groups = ["main", "dev"]
files = [
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
@@ -236,6 +265,7 @@ files = [
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
+markers = {main = "platform_python_implementation != \"PyPy\"", dev = "implementation_name == \"pypy\""}
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
@@ -268,6 +298,21 @@ files = [
]
markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
+[[package]]
+name = "comm"
+version = "0.2.3"
+description = "Jupyter Python Comm implementation, for usage in ipykernel, xeus-python etc."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417"},
+ {file = "comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971"},
+]
+
+[package.extras]
+test = ["pytest"]
+
[[package]]
name = "coverage"
version = "7.10.7"
@@ -447,6 +492,61 @@ cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy
[package.extras]
ssh = ["bcrypt (>=3.1.5)"]
+[[package]]
+name = "debugpy"
+version = "1.8.21"
+description = "An implementation of the Debug Adapter Protocol for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9"},
+ {file = "debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344"},
+ {file = "debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73"},
+ {file = "debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5"},
+ {file = "debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264"},
+ {file = "debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc"},
+ {file = "debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e"},
+ {file = "debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7"},
+ {file = "debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e"},
+ {file = "debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176"},
+ {file = "debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9"},
+ {file = "debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c"},
+ {file = "debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88"},
+ {file = "debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2"},
+ {file = "debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1"},
+ {file = "debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0"},
+ {file = "debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782"},
+ {file = "debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e"},
+ {file = "debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c"},
+ {file = "debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8"},
+ {file = "debugpy-1.8.21-cp38-cp38-macosx_15_0_x86_64.whl", hash = "sha256:0042da0ecd0a8b50dc4a54395ecd870d258d73fa18776f50c91fdcabdcad2675"},
+ {file = "debugpy-1.8.21-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:ffd932c6796afadab6993ec96745918a8cb2444dbd392074f769db5ea40ab440"},
+ {file = "debugpy-1.8.21-cp38-cp38-win32.whl", hash = "sha256:4e7c2d784d78ad4b71a5f8cd7b59c167719ec8a7a0211dbb3eb1bfeda78bc4e2"},
+ {file = "debugpy-1.8.21-cp38-cp38-win_amd64.whl", hash = "sha256:aa9d941d6dfe3d0407e4b3ca0b9ec466030e260fbf1174094f68785680f66db6"},
+ {file = "debugpy-1.8.21-cp39-cp39-macosx_15_0_x86_64.whl", hash = "sha256:9f5171176a0084b95d2ebe55a4d1f7b2a75b74c5dbec577ebd3a85c740551c36"},
+ {file = "debugpy-1.8.21-cp39-cp39-manylinux_2_34_x86_64.whl", hash = "sha256:f15c10084f9861b5e8414a48f18f8e4aadf51a98a59e72c16aa28281ca994672"},
+ {file = "debugpy-1.8.21-cp39-cp39-win32.whl", hash = "sha256:4e70cc8b5079f885cb43910924ee0aab73b8b6b2a14eff23afdd9895d86e79eb"},
+ {file = "debugpy-1.8.21-cp39-cp39-win_amd64.whl", hash = "sha256:e935f9dc0501be523c8a8e1853c39432e1354e9ece717ae5998fd2371c4542c3"},
+ {file = "debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92"},
+ {file = "debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6"},
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+description = "Get the currently executing AST node of a frame, and other information"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"},
+ {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"},
+]
+
+[package.extras]
+tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""]
+
[[package]]
name = "fastapi"
version = "0.116.2"
@@ -720,6 +820,169 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
+[[package]]
+name = "ipykernel"
+version = "6.31.0"
+description = "IPython Kernel for Jupyter"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af"},
+ {file = "ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6"},
+]
+
+[package.dependencies]
+appnope = {version = ">=0.1.2", markers = "platform_system == \"Darwin\""}
+comm = ">=0.1.1"
+debugpy = ">=1.6.5"
+ipython = ">=7.23.1"
+jupyter-client = ">=8.0.0"
+jupyter-core = ">=4.12,<5.0.dev0 || >=5.1.dev0"
+matplotlib-inline = ">=0.1"
+nest-asyncio = ">=1.4"
+packaging = ">=22"
+psutil = ">=5.7"
+pyzmq = ">=25"
+tornado = ">=6.2"
+traitlets = ">=5.4.0"
+
+[package.extras]
+cov = ["coverage[toml]", "matplotlib", "pytest-cov", "trio"]
+docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling", "trio"]
+pyqt5 = ["pyqt5"]
+pyside6 = ["pyside6"]
+test = ["flaky", "ipyparallel", "pre-commit", "pytest (>=7.0,<9)", "pytest-asyncio (>=0.23.5)", "pytest-cov", "pytest-timeout"]
+
+[[package]]
+name = "ipython"
+version = "9.16.1"
+description = "IPython: Productive Interactive Computing"
+optional = false
+python-versions = ">=3.11"
+groups = ["dev"]
+files = [
+ {file = "ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4"},
+ {file = "ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4.4", markers = "sys_platform == \"win32\""}
+ipython-pygments-lexers = ">=1.0.0"
+jedi = ">=0.18.2"
+matplotlib-inline = ">=0.1.6"
+pexpect = {version = ">4.6", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""}
+prompt_toolkit = ">=3.0.41,<3.1.0"
+psutil = {version = ">=7", markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\""}
+pygments = ">=2.14.0"
+stack_data = ">=0.6.0"
+traitlets = ">=5.13.0"
+
+[package.extras]
+all = ["argcomplete (>=3.0)", "ipython[doc,matplotlib,test,test-extra]"]
+black = ["black"]
+doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[matplotlib,test]", "setuptools (>=80.0)", "sphinx (>=8.0)", "sphinx-rtd-theme (>=0.1.8)", "sphinx_toml (==0.0.4)", "typing_extensions"]
+matplotlib = ["matplotlib (>3.9)"]
+test = ["packaging (>=23.0.0)", "pytest (>=7.0.0)", "pytest-asyncio (>=1.0.0)", "setuptools (>=80.0)", "testpath (>=0.2)"]
+test-extra = ["curio", "ipykernel (>6.30)", "ipython[matplotlib]", "ipython[test]", "jupyter_ai", "nbclient", "nbformat", "numpy (>=2.0)", "pandas (>2.1)", "trio (>=0.22.0)"]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+description = "Defines a variety of Pygments lexers for highlighting IPython code."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"},
+ {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"},
+]
+
+[package.dependencies]
+pygments = "*"
+
+[[package]]
+name = "jedi"
+version = "0.20.0"
+description = "An autocompletion tool for Python that can be used for text editors."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"},
+ {file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"},
+]
+
+[package.dependencies]
+parso = ">=0.8.6,<0.9.0"
+
+[package.extras]
+dev = ["Django", "attrs", "colorama", "docopt", "flake8 (==7.1.2)", "pytest (<9.0.0)", "types-setuptools (==80.9.0.20250529)", "typing-extensions", "zuban (==0.7.0)"]
+docs = ["Jinja2 (==3.1.6)", "MarkupSafe (==3.0.3)", "Pygments (==2.20.0)", "Sphinx (==9.1.0)", "alabaster (==1.0.0)", "babel (==2.18.0)", "certifi (==2026.4.22)", "charset-normalizer (==3.4.7)", "docutils (==0.22.4)", "idna (==3.13)", "imagesize (==2.0.0)", "iniconfig (==2.3.0)", "packaging (==26.2)", "pluggy (==1.6.0)", "pytest (==9.0.3)", "requests (==2.33.1)", "roman-numerals (==4.1.0)", "snowballstemmer (==3.0.1)", "sphinx-rtd-theme (==3.1.0)", "sphinxcontrib-applehelp (==2.0.0)", "sphinxcontrib-devhelp (==2.0.0)", "sphinxcontrib-htmlhelp (==2.1.0)", "sphinxcontrib-jquery (==4.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==2.0.0)", "sphinxcontrib-serializinghtml (==2.0.0)", "urllib3 (==2.6.3)"]
+
+[[package]]
+name = "joserfc"
+version = "1.7.4"
+description = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463"},
+ {file = "joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed"},
+]
+
+[package.dependencies]
+cryptography = ">=45.0.1"
+
+[package.extras]
+drafts = ["pycryptodome"]
+
+[[package]]
+name = "jupyter-client"
+version = "8.9.1"
+description = "Jupyter protocol implementation and client libraries"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81"},
+ {file = "jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa"},
+]
+
+[package.dependencies]
+jupyter-core = ">=5.1"
+python-dateutil = ">=2.8.2"
+pyzmq = ">=25.0"
+tornado = ">=6.4.1"
+traitlets = ">=5.3"
+typing-extensions = ">=4.13.0"
+
+[package.extras]
+docs = ["ipykernel", "myst-parser", "pydata-sphinx-theme", "sphinx (>=4)", "sphinx-autodoc-typehints", "sphinxcontrib-github-alt", "sphinxcontrib-spelling"]
+orjson = ["orjson"]
+test = ["anyio", "coverage", "ipykernel (>=6.14)", "msgpack", "mypy ; platform_python_implementation != \"PyPy\"", "paramiko ; sys_platform == \"win32\"", "pre-commit", "pytest", "pytest-cov", "pytest-jupyter[client] (>=0.6.2)", "pytest-timeout"]
+
+[[package]]
+name = "jupyter-core"
+version = "5.9.1"
+description = "Jupyter core package. A base package on which Jupyter projects rely."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407"},
+ {file = "jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508"},
+]
+
+[package.dependencies]
+platformdirs = ">=2.5"
+traitlets = ">=5.3"
+
+[package.extras]
+docs = ["intersphinx-registry", "myst-parser", "pydata-sphinx-theme", "sphinx-autodoc-typehints", "sphinxcontrib-spelling", "traitlets"]
+test = ["ipykernel", "pre-commit", "pytest (<9)", "pytest-cov", "pytest-timeout"]
+
[[package]]
name = "mako"
version = "1.3.10"
@@ -839,6 +1102,24 @@ files = [
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
]
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.2"
+description = "Inline Matplotlib backend for Jupyter"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"},
+ {file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"},
+]
+
+[package.dependencies]
+traitlets = "*"
+
+[package.extras]
+test = ["flake8", "matplotlib", "nbdime", "nbval", "notebook", "pytest"]
+
[[package]]
name = "more-itertools"
version = "10.8.0"
@@ -851,6 +1132,18 @@ files = [
{file = "more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd"},
]
+[[package]]
+name = "nest-asyncio"
+version = "1.6.0"
+description = "Patch asyncio to allow nested event loops"
+optional = false
+python-versions = ">=3.5"
+groups = ["dev"]
+files = [
+ {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"},
+ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
+]
+
[[package]]
name = "orjson"
version = "3.11.6"
@@ -947,6 +1240,50 @@ files = [
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
+[[package]]
+name = "parso"
+version = "0.8.7"
+description = "A Python Parser"
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+files = [
+ {file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"},
+ {file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"},
+]
+
+[package.extras]
+qa = ["flake8 (==5.0.4)", "types-setuptools (==67.2.0.1)", "zuban (==0.5.1)"]
+testing = ["docopt", "pytest"]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+description = "Pexpect allows easy control of interactive console applications."
+optional = false
+python-versions = "*"
+groups = ["dev"]
+markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
+files = [
+ {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
+ {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
+]
+
+[package.dependencies]
+ptyprocess = ">=0.5"
+
+[[package]]
+name = "platformdirs"
+version = "4.11.3"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7"},
+ {file = "platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab"},
+]
+
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -963,6 +1300,56 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.53"
+description = "Library for building powerful interactive command lines in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2"},
+ {file = "prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6"},
+]
+
+[package.dependencies]
+wcwidth = ">=0.1.4"
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+description = "Cross-platform lib for process and system monitoring."
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+files = [
+ {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
+ {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"},
+ {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"},
+ {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"},
+ {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"},
+ {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"},
+ {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"},
+ {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"},
+ {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"},
+ {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"},
+ {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"},
+ {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"},
+ {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"},
+ {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"},
+ {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"},
+ {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"},
+ {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"},
+ {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"},
+ {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"},
+ {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"},
+ {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"},
+]
+
+[package.extras]
+dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
+test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
+
[[package]]
name = "psycopg"
version = "3.2.10"
@@ -1060,18 +1447,46 @@ files = [
{file = "psycopg_binary-3.2.10-cp39-cp39-win_amd64.whl", hash = "sha256:6220d6efd6e2df7b67d70ed60d653106cd3b70c5cb8cbe4e9f0a142a5db14015"},
]
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+description = "Run a subprocess in a pseudo terminal"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
+files = [
+ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
+ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+description = "Safely evaluate AST nodes without side effects"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
+ {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
+]
+
+[package.extras]
+tests = ["pytest"]
+
[[package]]
name = "pycparser"
version = "2.23"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
-groups = ["main"]
-markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
+groups = ["main", "dev"]
files = [
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
+markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", dev = "implementation_name == \"pypy\""}
[[package]]
name = "pydantic"
@@ -1328,6 +1743,21 @@ pytest = ">=6.2.5"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["dev"]
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1438,6 +1868,111 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
+[[package]]
+name = "pyzmq"
+version = "27.1.0"
+description = "Python bindings for 0MQ"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4"},
+ {file = "pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556"},
+ {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b"},
+ {file = "pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e"},
+ {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526"},
+ {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1"},
+ {file = "pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386"},
+ {file = "pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda"},
+ {file = "pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f"},
+ {file = "pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32"},
+ {file = "pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86"},
+ {file = "pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581"},
+ {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f"},
+ {file = "pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e"},
+ {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e"},
+ {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2"},
+ {file = "pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394"},
+ {file = "pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f"},
+ {file = "pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97"},
+ {file = "pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07"},
+ {file = "pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc"},
+ {file = "pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113"},
+ {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233"},
+ {file = "pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31"},
+ {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28"},
+ {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856"},
+ {file = "pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496"},
+ {file = "pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd"},
+ {file = "pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf"},
+ {file = "pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f"},
+ {file = "pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5"},
+ {file = "pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2"},
+ {file = "pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0"},
+ {file = "pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7"},
+ {file = "pyzmq-27.1.0-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:18339186c0ed0ce5835f2656cdfb32203125917711af64da64dbaa3d949e5a1b"},
+ {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:753d56fba8f70962cd8295fb3edb40b9b16deaa882dd2b5a3a2039f9ff7625aa"},
+ {file = "pyzmq-27.1.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b721c05d932e5ad9ff9344f708c96b9e1a485418c6618d765fca95d4daacfbef"},
+ {file = "pyzmq-27.1.0-cp38-cp38-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be883ff3d722e6085ee3f4afc057a50f7f2e0c72d289fd54df5706b4e3d3a50"},
+ {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:b2e592db3a93128daf567de9650a2f3859017b3f7a66bc4ed6e4779d6034976f"},
+ {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad68808a61cbfbbae7ba26d6233f2a4aa3b221de379ce9ee468aa7a83b9c36b0"},
+ {file = "pyzmq-27.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e2687c2d230e8d8584fbea433c24382edfeda0c60627aca3446aa5e58d5d1831"},
+ {file = "pyzmq-27.1.0-cp38-cp38-win32.whl", hash = "sha256:a1aa0ee920fb3825d6c825ae3f6c508403b905b698b6460408ebd5bb04bbb312"},
+ {file = "pyzmq-27.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:df7cd397ece96cf20a76fae705d40efbab217d217897a5053267cd88a700c266"},
+ {file = "pyzmq-27.1.0-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:96c71c32fff75957db6ae33cd961439f386505c6e6b377370af9b24a1ef9eafb"},
+ {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:49d3980544447f6bd2968b6ac913ab963a49dcaa2d4a2990041f16057b04c429"},
+ {file = "pyzmq-27.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:849ca054d81aa1c175c49484afaaa5db0622092b5eccb2055f9f3bb8f703782d"},
+ {file = "pyzmq-27.1.0-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3970778e74cb7f85934d2b926b9900e92bfe597e62267d7499acc39c9c28e345"},
+ {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:da96ecdcf7d3919c3be2de91a8c513c186f6762aa6cf7c01087ed74fad7f0968"},
+ {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9541c444cfe1b1c0156c5c86ece2bb926c7079a18e7b47b0b1b3b1b875e5d098"},
+ {file = "pyzmq-27.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e30a74a39b93e2e1591b58eb1acef4902be27c957a8720b0e368f579b82dc22f"},
+ {file = "pyzmq-27.1.0-cp39-cp39-win32.whl", hash = "sha256:b1267823d72d1e40701dcba7edc45fd17f71be1285557b7fe668887150a14b78"},
+ {file = "pyzmq-27.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0c996ded912812a2fcd7ab6574f4ad3edc27cb6510349431e4930d4196ade7db"},
+ {file = "pyzmq-27.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:346e9ba4198177a07e7706050f35d733e08c1c1f8ceacd5eb6389d653579ffbc"},
+ {file = "pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6"},
+ {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90"},
+ {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62"},
+ {file = "pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74"},
+ {file = "pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba"},
+ {file = "pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066"},
+ {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604"},
+ {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c"},
+ {file = "pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271"},
+ {file = "pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355"},
+ {file = "pyzmq-27.1.0-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:50081a4e98472ba9f5a02850014b4c9b629da6710f8f14f3b15897c666a28f1b"},
+ {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:510869f9df36ab97f89f4cff9d002a89ac554c7ac9cadd87d444aa4cf66abd27"},
+ {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f8426a01b1c4098a750973c37131cf585f61c7911d735f729935a0c701b68d3"},
+ {file = "pyzmq-27.1.0-pp38-pypy38_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:726b6a502f2e34c6d2ada5e702929586d3ac948a4dbbb7fed9854ec8c0466027"},
+ {file = "pyzmq-27.1.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:bd67e7c8f4654bef471c0b1ca6614af0b5202a790723a58b79d9584dc8022a78"},
+ {file = "pyzmq-27.1.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:722ea791aa233ac0a819fc2c475e1292c76930b31f1d828cb61073e2fe5e208f"},
+ {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:01f9437501886d3a1dd4b02ef59fb8cc384fa718ce066d52f175ee49dd5b7ed8"},
+ {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4a19387a3dddcc762bfd2f570d14e2395b2c9701329b266f83dd87a2b3cbd381"},
+ {file = "pyzmq-27.1.0-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c618fbcd069e3a29dcd221739cacde52edcc681f041907867e0f5cc7e85f172"},
+ {file = "pyzmq-27.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff8d114d14ac671d88c89b9224c63d6c4e5a613fe8acd5594ce53d752a3aafe9"},
+ {file = "pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540"},
+]
+
+[package.dependencies]
+cffi = {version = "*", markers = "implementation_name == \"pypy\""}
+
[[package]]
name = "ruff"
version = "0.16.1"
@@ -1466,6 +2001,18 @@ files = [
{file = "ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7"},
]
+[[package]]
+name = "six"
+version = "1.17.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["dev"]
+files = [
+ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"
@@ -1594,6 +2141,26 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+description = "Extract data from python stack frames and tracebacks for informative displays"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
+ {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
+]
+
+[package.dependencies]
+asttokens = ">=2.1.0"
+executing = ">=1.2.0"
+pure-eval = "*"
+
+[package.extras]
+tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
+
[[package]]
name = "starlette"
version = "0.48.0"
@@ -1624,6 +2191,42 @@ files = [
{file = "structlog-25.4.0.tar.gz", hash = "sha256:186cd1b0a8ae762e29417095664adf1d6a31702160a46dacb7796ea82f7409e4"},
]
+[[package]]
+name = "tornado"
+version = "6.5.8"
+description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403"},
+ {file = "tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb"},
+ {file = "tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58"},
+ {file = "tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808"},
+ {file = "tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22"},
+ {file = "tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195"},
+ {file = "tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836"},
+ {file = "tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee"},
+ {file = "tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26"},
+ {file = "tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f"},
+]
+
+[[package]]
+name = "traitlets"
+version = "5.16.1"
+description = "Traitlets Python configuration system"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b"},
+ {file = "traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1"},
+]
+
+[package.extras]
+docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"]
+test = ["argcomplete (>=3.0.3) ; python_version < \"3.12\"", "argcomplete (>=3.5.2) ; python_version >= \"3.12\"", "mypy (>=2.0) ; implementation_name != \"pypy\"", "pre-commit", "pytest (>=7.0,<10.0)", "pytest-mock", "pytest-mypy-testing ; implementation_name != \"pypy\""]
+
[[package]]
name = "typeguard"
version = "4.4.4"
@@ -1876,6 +2479,18 @@ files = [
[package.dependencies]
anyio = ">=3.0.0"
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+description = "Measures the displayed width of unicode strings in a terminal"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85"},
+ {file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"},
+]
+
[[package]]
name = "websockets"
version = "15.0.1"
@@ -1958,4 +2573,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = "^3.13"
-content-hash = "37f1e42ac8e312f391cced0b1f8c98685b7454d2a2e6ce8aa3f9a39a843692ca"
+content-hash = "660be1b1daa7877ea55367d957003e37f3fe70cec7fc648d0a05af7539502644"
diff --git a/oid4vc/auth_server/pyproject.toml b/oid4vc/auth_server/pyproject.toml
index b7529ab5f..1326d4ceb 100644
--- a/oid4vc/auth_server/pyproject.toml
+++ b/oid4vc/auth_server/pyproject.toml
@@ -10,7 +10,7 @@ python = "^3.13"
orjson = "^3.11.6"
fastapi = "^0.116"
uvicorn = { extras = ["standard"], version = ">=0.35,<0.52" }
-authlib = "^1.6"
+authlib = "^1.7.2"
SQLAlchemy = { version = "^2.0", extras = ["asyncio"] }
asyncpg = "^0.30"
psycopg = { extras = ["binary"], version = "^3.2.9" }
@@ -27,10 +27,11 @@ pytest-asyncio = "^1.1"
pytest-cov = "^6.0"
coverage = "^7.6"
ruff = "^0.16.0"
+ipykernel = "^6.29"
[tool.ruff]
line-length = 90
-extend-exclude = ["alembic/**/versions/*"]
+extend-exclude = ["alembic/**/versions/*", "**/*.ipynb"]
[tool.ruff.format]
quote-style = "double"
diff --git a/oid4vc/auth_server/resources/.env.admin.example b/oid4vc/auth_server/resources/.env.admin.example
index c76a55cef..982958ee4 100644
--- a/oid4vc/auth_server/resources/.env.admin.example
+++ b/oid4vc/auth_server/resources/.env.admin.example
@@ -1,34 +1,43 @@
# Admin API environment (example values; replace in real use)
+# App metadata
+# ADMIN_APP_ROOT_PATH=
+# ADMIN_APP_TITLE=OAuth 2.0 Authorization Server Admin API
+# ADMIN_APP_VERSION=0.1.0
+# ADMIN_OPENAPI_URL=
+
+# Bearer tokens
+ADMIN_INTERNAL_AUTH_TOKEN=******
+ADMIN_MANAGE_AUTH_TOKEN=******
+
+# Database
+# ADMIN_DB_DRIVER_ASYNC=postgresql+asyncpg
+# ADMIN_DB_DRIVER_SYNC=postgresql+psycopg
ADMIN_DB_USER=postgres
ADMIN_DB_PASSWORD=postgres
ADMIN_DB_HOST=localhost
ADMIN_DB_PORT=5432
ADMIN_DB_NAME=auth_server_admin
ADMIN_DB_SCHEMA=admin
+# Pool is per replica; total connections = replicas x (size + overflow)
+# ADMIN_DB_POOL_SIZE=5
+# ADMIN_DB_MAX_OVERFLOW=10
+# ADMIN_DB_POOL_RECYCLE=1800
-# Bearer token for internal admin endpoints (masked)
-ADMIN_INTERNAL_AUTH_TOKEN=******
+# Tenant database
+# ADMIN_TENANT_DB_NAME=auth_server_tenant
+# ADMIN_TENANT_DB_SCHEMA=auth
-# Optional: envelope encryption for secrets (JSON map of version->key)
+# Client settings
+# ADMIN_MIN_CLIENT_SECRET_LENGTH=32
+
+# Key encryption
# ADMIN_KEY_ENC_SECRETS={"1":""}
# ADMIN_KEY_ENC_VERSION=1
+# ADMIN_KEY_VERIFY_GRACE_TTL=604800
-# Optional: CORS settings
-# ADMIN_CORS_ALLOW_ORIGINS=*
+# CORS
+# ADMIN_CORS_ALLOW_ORIGINS=[]
# ADMIN_CORS_ALLOW_METHODS=GET,POST,PATCH,DELETE,OPTIONS
# ADMIN_CORS_ALLOW_HEADERS=Authorization,Content-Type
-# ADMIN_CORS_ALLOW_CREDENTIALS=false
-
-# Optional: Application metadata
-# ADMIN_APP_ROOT_PATH=
-# ADMIN_APP_TITLE=OAuth 2.0 Authorization Server Admin API
-# ADMIN_APP_VERSION=0.1.0
-# ADMIN_OPENAPI_URL=
-
-# Optional: Tenant DB config
-# ADMIN_TENANT_DB_NAME=auth_server_tenant
-# ADMIN_TENANT_DB_SCHEMA=auth
-
-# Optional: Key verification grace period (in seconds)
-# ADMIN_KEY_VERIFY_GRACE_TTL=604800
\ No newline at end of file
+# ADMIN_CORS_ALLOW_CREDENTIALS=false
\ No newline at end of file
diff --git a/oid4vc/auth_server/resources/.env.demo.example b/oid4vc/auth_server/resources/.env.demo.example
new file mode 100644
index 000000000..1c14deaa1
--- /dev/null
+++ b/oid4vc/auth_server/resources/.env.demo.example
@@ -0,0 +1,21 @@
+# Attestation demo notebook environment (example values; copy to .env.demo).
+# The .env.demo file is gitignored; this .example file is committed.
+
+# Tenant / client identifiers
+DEMO_TENANT_UID=
+DEMO_ISSUER_CLIENT_ID=
+DEMO_ISSUER_CLIENT_SECRET=
+
+# Server URLs
+DEMO_ADMIN_URL=http://localhost:9000
+DEMO_TENANT_URL=http://localhost:9001
+
+# Admin manage token (Authorization: Bearer )
+DEMO_MANAGE_TOKEN=
+
+# Wallet provider metadata
+DEMO_WALLET_PROVIDER_ISS=https://wallet-provider.example.com
+DEMO_ATTESTATION_TTL=86400
+
+# Positive if the server clock is ahead of your local clock (seconds).
+DEMO_CLOCK_OFFSET=0
diff --git a/oid4vc/auth_server/resources/.env.tenant.example b/oid4vc/auth_server/resources/.env.tenant.example
index 2f099a2e8..9332c312a 100644
--- a/oid4vc/auth_server/resources/.env.tenant.example
+++ b/oid4vc/auth_server/resources/.env.tenant.example
@@ -1,27 +1,56 @@
# Tenant API environment (example values; replace in real use)
+# App metadata
+# TENANT_APP_ROOT_PATH=
+# TENANT_APP_TITLE=OAuth 2.0 Authorization Server Tenant API
+# TENANT_APP_VERSION=0.1.0
+# TENANT_OPENAPI_URL=
TENANT_ISSUER_BASE_URL=http://localhost:9001
-# Admin Internal base used by tenant to fetch per-tenant DB/JWKS
+# Token settings
+# OID4VCI §13.10: bearer access tokens >5 min MUST NOT be issued; set ≤300
+# TENANT_ACCESS_TOKEN_TTL=300
+# TENANT_REFRESH_TOKEN_TTL=604800
+# TENANT_PRE_AUTH_CODE_TTL=600
+# TENANT_MAX_TX_CODE_ATTEMPTS=3
+# TENANT_TOKEN_BYTES=48
+# TENANT_INCLUDE_NONCE=false
+# TENANT_NONCE_BYTES=16
+
+# Attestation-Based Client Auth (draft-ietf-oauth-attestation-based-client-auth-07)
+# Set ENABLED=false to fully disable; REQUIRED=true requires ENABLED=true
+# TENANT_ATTESTATION_ENABLED=true
+# TENANT_ATTESTATION_REQUIRED=false
+# TENANT_ATTESTATION_CLOCK_SKEW_SECONDS=60
+
+# Database
+# TENANT_DB_DRIVER_ASYNC=postgresql+asyncpg
+# TENANT_DB_DRIVER_SYNC=postgresql+psycopg
+# TENANT_DB_HOST=localhost
+# TENANT_DB_PORT=5432
+# Pool is per replica; total connections = replicas x tenant DBs x (size + overflow)
+# TENANT_DB_POOL_SIZE=5
+# TENANT_DB_MAX_OVERFLOW=10
+# TENANT_DB_POOL_RECYCLE=1800
+
+# Networking
+# TENANT_TRUSTED_NETWORKS=["127.0.0.0/8","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16"]
TENANT_INTERNAL_BASE_URL=http://localhost:9000/internal
TENANT_INTERNAL_AUTH_TOKEN=******
-# Caching TTLs (seconds)
+# Caching
# TENANT_CONTEXT_CACHE_TTL=900
+# TENANT_WELL_KNOWN_CACHE_TTL=300
-# Token TTLs (seconds)
-# TENANT_ACCESS_TOKEN_TTL=900
-# TENANT_REFRESH_TOKEN_TTL=604800
-# TENANT_PRE_AUTH_CODE_TTL=600
+# Key encryption
+# TENANT_KEY_ENC_SECRETS={"1":""}
+# TENANT_KEY_ENC_VERSION=1
-# Optional networking and CORS
-# TENANT_TRUSTED_NETWORKS= # e.g., 10.0.0.0/8,192.168.0.0/16
-# TENANT_CORS_ALLOW_ORIGINS=*
+# CORS
+# TENANT_CORS_ALLOW_ORIGINS=[]
# TENANT_CORS_ALLOW_METHODS=GET,POST,OPTIONS
# TENANT_CORS_ALLOW_HEADERS=Authorization,Content-Type
+# TENANT_CORS_ALLOW_CREDENTIALS=false
-# Optional: Application metadata
-# TENANT_APP_ROOT_PATH=
-# TENANT_APP_TITLE=
-# TENANT_APP_VERSION=
-# TENANT_OPENAPI_URL=
\ No newline at end of file
+# Proxy
+# TENANT_PROXY_TRUSTED_HOSTS=127.0.0.1
\ No newline at end of file
diff --git a/oid4vc/auth_server/resources/attestation_demo.ipynb b/oid4vc/auth_server/resources/attestation_demo.ipynb
new file mode 100644
index 000000000..4f46a4abc
--- /dev/null
+++ b/oid4vc/auth_server/resources/attestation_demo.ipynb
@@ -0,0 +1,363 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "19199d24",
+ "metadata": {},
+ "source": [
+ "# Attestation Generator\n",
+ "\n",
+ "Generates all the intermediate payloads for the **OID4VCI attestation** flow.\n",
+ "Copy the output into **Swagger UI** or **Postman** to execute each step.\n",
+ "\n",
+ "**Prerequisites:**\n",
+ "- Admin server running on localhost:9000\n",
+ "- Tenant server running on localhost:9001\n",
+ "- A seeded tenant + issuer client (run `dev_seed.py` first)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bbfc4a58",
+ "metadata": {},
+ "source": [
+ "## Imports & Helpers"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "98a14c9e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import base64\n",
+ "import hashlib\n",
+ "import json\n",
+ "import time\n",
+ "from typing import Any\n",
+ "\n",
+ "from joserfc import jwk, jwt as jose_jwt\n",
+ "from IPython.display import JSON, display, Markdown"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "19352eb0",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "\n",
+ "KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(\"__file__\")), \".demo_keys.json\")\n",
+ "\n",
+ "\n",
+ "def _gen_ec_key() -> tuple[dict[str, Any], dict[str, Any]]:\n",
+ " \"\"\"Generate an ES256 key pair, returning (private_jwk, public_jwk).\"\"\"\n",
+ " key = jwk.generate_key(\"EC\", \"P-256\")\n",
+ " private_jwk = key.as_dict(private=True)\n",
+ " public_jwk = key.as_dict(private=False)\n",
+ " return private_jwk, public_jwk\n",
+ "\n",
+ "\n",
+ "def _load_keys() -> dict[str, Any]:\n",
+ " \"\"\"Load persisted keys from disk, or return empty dict.\"\"\"\n",
+ " if os.path.exists(KEYS_FILE):\n",
+ " with open(KEYS_FILE, \"r\") as f:\n",
+ " return json.load(f)\n",
+ " return {}\n",
+ "\n",
+ "\n",
+ "def _save_keys(data: dict[str, Any]) -> None:\n",
+ " \"\"\"Persist keys to disk.\"\"\"\n",
+ " with open(KEYS_FILE, \"w\") as f:\n",
+ " json.dump(data, f, indent=2)\n",
+ "\n",
+ "\n",
+ "def _get_or_create_keypair(label: str) -> tuple[dict[str, Any], dict[str, Any], bool]:\n",
+ " \"\"\"Load keypair from disk if it exists, otherwise generate and save.\n",
+ "\n",
+ " Returns (private_jwk, public_jwk, was_loaded_from_disk).\n",
+ " \"\"\"\n",
+ " store = _load_keys()\n",
+ " if label in store:\n",
+ " entry = store[label]\n",
+ " return entry[\"private\"], entry[\"public\"], True\n",
+ " private_jwk, public_jwk = _gen_ec_key()\n",
+ " store[label] = {\"private\": private_jwk, \"public\": public_jwk}\n",
+ " _save_keys(store)\n",
+ " return private_jwk, public_jwk, False\n",
+ "\n",
+ "\n",
+ "def _thumbprint(j: dict[str, Any]) -> str:\n",
+ " \"\"\"Compute RFC 7638 JWK thumbprint (base64url, no padding).\"\"\"\n",
+ " ordered = {\"crv\": j[\"crv\"], \"kty\": j[\"kty\"], \"x\": j[\"x\"], \"y\": j[\"y\"]}\n",
+ " canonical = json.dumps(ordered, separators=(\",\", \":\"), sort_keys=True).encode()\n",
+ " digest = hashlib.sha256(canonical).digest()\n",
+ " return base64.urlsafe_b64encode(digest).decode().rstrip(\"=\")\n",
+ "\n",
+ "\n",
+ "def _sign_jwt(\n",
+ " payload: dict[str, Any],\n",
+ " private_jwk: dict[str, Any],\n",
+ " header_extra: dict[str, Any] | None = None,\n",
+ ") -> str:\n",
+ " \"\"\"Sign a JWT with joserfc.\"\"\"\n",
+ " header = {\"alg\": \"ES256\", \"typ\": \"JWT\"}\n",
+ " if header_extra:\n",
+ " header.update(header_extra)\n",
+ " key = jwk.import_key(private_jwk)\n",
+ " token = jose_jwt.encode(header, payload, key)\n",
+ " return token\n",
+ "\n",
+ "\n",
+ "def _decode_jwt_part(token: str, index: int) -> dict[str, Any]:\n",
+ " \"\"\"Decode a JWT header (0) or payload (1) for display.\"\"\"\n",
+ " part = token.split(\".\")[index]\n",
+ " padded = part + \"=\" * (-len(part) % 4)\n",
+ " return json.loads(base64.urlsafe_b64decode(padded))\n",
+ "\n",
+ "\n",
+ "def show(label: str, data):\n",
+ " \"\"\"Pretty-print a labelled JSON blob.\"\"\"\n",
+ " display(Markdown(f\"### {label}\"))\n",
+ " display(JSON(data))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "79ddf657",
+ "metadata": {},
+ "source": [
+ "## Configuration"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "97c57632",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from dotenv import load_dotenv\n",
+ "\n",
+ "load_dotenv(\".env.demo\")\n",
+ "\n",
+ "TENANT_UID = os.environ[\"DEMO_TENANT_UID\"]\n",
+ "ISSUER_CLIENT_ID = os.environ[\"DEMO_ISSUER_CLIENT_ID\"]\n",
+ "ISSUER_CLIENT_SECRET = os.environ[\"DEMO_ISSUER_CLIENT_SECRET\"]\n",
+ "\n",
+ "ADMIN_URL = os.environ[\"DEMO_ADMIN_URL\"]\n",
+ "TENANT_URL = os.environ[\"DEMO_TENANT_URL\"]\n",
+ "MANAGE_TOKEN = os.environ[\"DEMO_MANAGE_TOKEN\"]\n",
+ "\n",
+ "WALLET_PROVIDER_ISS = os.environ[\"DEMO_WALLET_PROVIDER_ISS\"]\n",
+ "ATTESTATION_TTL = int(os.environ[\"DEMO_ATTESTATION_TTL\"])\n",
+ "CLOCK_OFFSET = int(os.environ[\"DEMO_CLOCK_OFFSET\"])\n",
+ "\n",
+ "print(f\"Tenant URL: {TENANT_URL}\")\n",
+ "print(f\"Tenant UID: {TENANT_UID}\")\n",
+ "print(f\"Issuer Client ID: {ISSUER_CLIENT_ID}\")\n",
+ "print(f\"Wallet Provider ISS: {WALLET_PROVIDER_ISS}\")\n",
+ "print(f\"Clock offset: {CLOCK_OFFSET}s\")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "94a25fcd",
+ "metadata": {},
+ "source": [
+ "## Step 1: Wallet Provider generates signing key pair\n",
+ "\n",
+ "The provider's private key signs attestation JWTs. The public key is registered on the admin server."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5f495b8c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "provider_private_jwk, provider_public_jwk, loaded = _get_or_create_keypair(\"provider\")\n",
+ "provider_kid = provider_public_jwk.get(\"kid\") or _thumbprint(provider_public_jwk)\n",
+ "\n",
+ "print(\"🔑 Loaded from disk\" if loaded else \"🆕 Generated new keypair (saved to disk)\")\n",
+ "show(\"Wallet Provider private JWK (kept secret)\", provider_private_jwk)\n",
+ "show(\"Wallet Provider public JWK (registered on admin)\", provider_public_jwk)\n",
+ "print(f\"provider_kid: {provider_kid}\")\n",
+ "print(f\"provider_iss: {WALLET_PROVIDER_ISS}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "44f76c3f",
+ "metadata": {},
+ "source": [
+ "## Step 2: Register Wallet Provider on Admin\n",
+ "\n",
+ "Use this payload to register the provider's public key on the admin allow list (via Swagger/Postman)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "448299a8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "wp_registration = {\n",
+ " \"iss\": WALLET_PROVIDER_ISS,\n",
+ " \"jwks\": {\"keys\": [{**provider_public_jwk, \"kid\": provider_kid}]},\n",
+ " \"name\": \"Demo Wallet Provider\",\n",
+ " \"active\": True,\n",
+ "}\n",
+ "\n",
+ "display(Markdown(f\"**POST** `{ADMIN_URL}/admin/wallet-providers`\"))\n",
+ "display(Markdown(f\"**Authorization:** `Bearer {MANAGE_TOKEN}`\"))\n",
+ "show(\"Request body\", wp_registration)\n",
+ "\n",
+ "display(Markdown(\"### Copy-paste JSON\"))\n",
+ "print(json.dumps(wp_registration, indent=2))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6bf50dc8",
+ "metadata": {},
+ "source": [
+ "## Step 3: Wallet generates its own key pair\n",
+ "\n",
+ "The wallet's public key will be embedded in the attestation JWT's `cnf.jwk` claim to bind the attestation to this specific wallet instance."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "cd85967b",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "wallet_private_jwk, wallet_public_jwk, loaded = _get_or_create_keypair(\"wallet\")\n",
+ "wallet_kid = (\n",
+ " wallet_public_jwk.get(\"kid\")\n",
+ " or _thumbprint(wallet_public_jwk)\n",
+ ")\n",
+ "\n",
+ "print(\"🔑 Loaded from disk\" if loaded else \"🆕 Generated new keypair (saved to disk)\")\n",
+ "show(\"Wallet private JWK (kept on device)\", wallet_private_jwk)\n",
+ "show(\n",
+ " \"Wallet public JWK (sent to provider)\",\n",
+ " wallet_public_jwk,\n",
+ ")\n",
+ "print(f\"wallet_kid (thumbprint): {wallet_kid}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8593dc59",
+ "metadata": {},
+ "source": [
+ "## Step 4: Wallet Provider issues Attestation JWT\n",
+ "\n",
+ "The provider signs an `oauth-client-attestation+jwt` binding it to the wallet's public key via the `cnf.jwk` claim.\n",
+ "This token is long-lived (1 day) and can be reused across multiple `/token` calls."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "f55aa9ca",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "now = int(time.time()) + CLOCK_OFFSET\n",
+ "\n",
+ "attestation_payload = {\n",
+ " \"iss\": WALLET_PROVIDER_ISS,\n",
+ " \"sub\": ISSUER_CLIENT_ID,\n",
+ " \"iat\": now,\n",
+ " \"exp\": now + ATTESTATION_TTL,\n",
+ " \"cnf\": {\"jwk\": wallet_public_jwk},\n",
+ "}\n",
+ "attestation_header = {\n",
+ " \"alg\": \"ES256\",\n",
+ " \"typ\": \"oauth-client-attestation+jwt\",\n",
+ " \"kid\": provider_kid,\n",
+ "}\n",
+ "key = jwk.import_key(provider_private_jwk)\n",
+ "attestation_jwt = jose_jwt.encode(attestation_header, attestation_payload, key)\n",
+ "\n",
+ "show(\"Attestation JWT header\", attestation_header)\n",
+ "show(\"Attestation JWT payload\", attestation_payload)\n",
+ "display(Markdown(\"### Signed Attestation JWT\"))\n",
+ "print(attestation_jwt)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a5431c0e",
+ "metadata": {},
+ "source": [
+ "## Step 5: Wallet creates Attestation PoP JWT & output headers\n",
+ "\n",
+ "The wallet signs an `oauth-client-attestation-pop+jwt` proving possession of the `cnf.jwk` private key.\n",
+ "Required claims per draft-08 §5.2: `aud`, `jti`, `iat`. Optional: `challenge` (if AS provides one).\n",
+ "\n",
+ "**Re-run this cell before each `/token` call** — the PoP needs a fresh `jti` (replay protection)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1766b550",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "now = int(time.time()) + CLOCK_OFFSET\n",
+ "\n",
+ "pop_payload = {\n",
+ " \"iat\": now,\n",
+ " \"exp\": now + ATTESTATION_TTL,\n",
+ " \"aud\": f\"{TENANT_URL}/tenants/{TENANT_UID}\",\n",
+ " \"jti\": base64.urlsafe_b64encode(\n",
+ " hashlib.sha256(str(now).encode()).digest()[:16]\n",
+ " ).decode().rstrip(\"=\"),\n",
+ "}\n",
+ "pop_header = {\n",
+ " \"alg\": \"ES256\",\n",
+ " \"typ\": \"oauth-client-attestation-pop+jwt\",\n",
+ "}\n",
+ "wallet_key = jwk.import_key(wallet_private_jwk)\n",
+ "pop_jwt = jose_jwt.encode(pop_header, pop_payload, wallet_key)\n",
+ "\n",
+ "show(\"PoP JWT header\", pop_header)\n",
+ "show(\"PoP JWT payload\", pop_payload)\n",
+ "display(Markdown(\"### Signed Attestation PoP JWT\"))\n",
+ "print(pop_jwt)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "auth-server-oJvz6-Yz-py3.13",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.13.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/oid4vc/auth_server/resources/dev_seed.py b/oid4vc/auth_server/resources/dev_seed.py
index 368452e58..68cee0364 100644
--- a/oid4vc/auth_server/resources/dev_seed.py
+++ b/oid4vc/auth_server/resources/dev_seed.py
@@ -11,7 +11,7 @@
import secrets
from typing import Any
-from authlib.jose import JsonWebKey
+from joserfc import jwk
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
@@ -36,8 +36,8 @@ def _gen_es256_keypair() -> tuple[str, dict[str, Any]]:
)
.decode("utf-8")
)
- public_jwk = JsonWebKey.import_key(public_pem).as_dict() # type: ignore
- return private_pem, public_jwk # type: ignore
+ public_jwk = jwk.import_key(public_pem).as_dict(private=False)
+ return private_pem, public_jwk
async def _ensure_tenant(svc: TenantService, uid: str, name: str):
@@ -79,18 +79,7 @@ async def main() -> None:
)
await svc.create_client(tenant.uid, pk_payload)
- # 2) shared_bearer (HS256)
- sb_client_id = f"dev-shared-{secrets.token_hex(4)}"
- sb_secret = secrets.token_urlsafe(32)
- sb_payload = ClientIn(
- client_id=sb_client_id,
- client_auth_method="shared_bearer",
- client_auth_signing_alg="HS256",
- client_secret=sb_secret,
- )
- await svc.create_client(tenant.uid, sb_payload)
-
- # 3) client_secret_basic (PBKDF2 stored)
+ # 2) client_secret_basic (PBKDF2 stored)
cs_client_id = f"dev-basic-{secrets.token_hex(4)}"
cs_secret = secrets.token_urlsafe(24)
cs_payload = ClientIn(
@@ -108,11 +97,6 @@ async def main() -> None:
print(" jwks (public):", jwks)
print(" private_key_pem (keep secret):\n", private_pem)
- print("\nshared_bearer client:")
- print(" client_id:", sb_client_id)
- print(" signing_alg: HS256")
- print(" shared_secret (keep secret):", sb_secret)
-
print("\nclient_secret_basic client:")
print(" client_id:", cs_client_id)
print(" client_secret (keep secret):", cs_secret)
diff --git a/oid4vc/auth_server/resources/seeds.txt b/oid4vc/auth_server/resources/seeds.txt
index 7f367ef46..238014012 100644
--- a/oid4vc/auth_server/resources/seeds.txt
+++ b/oid4vc/auth_server/resources/seeds.txt
@@ -12,11 +12,6 @@ uJIifTHLZ+V4eboAkbDMUvdpF6ShRANCAAQ6wBY6nQoMttW5f7p/ycoPRnC3faSy
-----END PRIVATE KEY-----
-shared_bearer client:
- client_id: dev-shared-7d2cb250
- signing_alg: HS256
- shared_secret (keep secret): hoqmL67QBlqXbk-myK0gVJLHjsHk6OrYjYcVcpgVtsM
-
client_secret_basic client:
client_id: dev-basic-ac699ffb
client_secret (keep secret): EYLdcOzRryToxTlaiIiWTDulEA5VPZoH
\ No newline at end of file
diff --git a/oid4vc/auth_server/tenant/config.py b/oid4vc/auth_server/tenant/config.py
index 0898ea361..4b4c99843 100644
--- a/oid4vc/auth_server/tenant/config.py
+++ b/oid4vc/auth_server/tenant/config.py
@@ -1,50 +1,73 @@
-"""Application configuration."""
+"""Tenant settings."""
+from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
- """Application configuration."""
+ """Tenant env vars (TENANT_ prefix)."""
model_config = SettingsConfigDict(env_prefix="TENANT_", extra="ignore")
+ # App metadata
APP_ROOT_PATH: str = ""
APP_TITLE: str = "OAuth 2.0 Authorization Server Tenant API"
APP_VERSION: str = "0.1.0"
OPENAPI_URL: str = ""
-
ISSUER_BASE_URL: str = "http://localhost:9001"
+ # Token settings
ACCESS_TOKEN_TTL: int = 900
REFRESH_TOKEN_TTL: int = 604800
PRE_AUTH_CODE_TTL: int = 600
+ MAX_TX_CODE_ATTEMPTS: int = 3
TOKEN_BYTES: int = 48
- INCLUDE_NONCE: bool = True
+ INCLUDE_NONCE: bool = False
NONCE_BYTES: int = 16
+ # Attestation
+ ATTESTATION_ENABLED: bool = True
+ ATTESTATION_REQUIRED: bool = False
+ ATTESTATION_CLOCK_SKEW_SECONDS: int = 60
+
+ # Database
DB_DRIVER_ASYNC: str = "postgresql+asyncpg"
DB_DRIVER_SYNC: str = "postgresql+psycopg"
DB_HOST: str = "localhost"
DB_PORT: int = 5432
+ # Sized per replica: total connections = replicas x tenant DBs x (size + overflow)
+ DB_POOL_SIZE: int = 5
+ DB_MAX_OVERFLOW: int = 10
+ DB_POOL_RECYCLE: int = 1800
+ # Networking
TRUSTED_NETWORKS: list[str] = []
-
INTERNAL_BASE_URL: str = "http://localhost:9000"
- INTERNAL_AUTH_TOKEN: str = "admin-internal-auth-token"
+ INTERNAL_AUTH_TOKEN: str = ""
+
+ # Caching
CONTEXT_CACHE_TTL: int = 900
WELL_KNOWN_CACHE_TTL: int = 300
+ # Key encryption
KEY_ENC_SECRETS: dict[str, str] = {}
KEY_ENC_VERSION: int = 1
- # CORS settings
- CORS_ALLOW_ORIGINS: list[str] = ["*"]
+ # CORS
+ CORS_ALLOW_ORIGINS: list[str] = []
CORS_ALLOW_METHODS: list[str] = ["GET", "POST", "OPTIONS"]
CORS_ALLOW_HEADERS: list[str] = ["Authorization", "Content-Type"]
CORS_ALLOW_CREDENTIALS: bool = False
- # Proxy settings
+ # Proxy
PROXY_TRUSTED_HOSTS: list[str] | str = "127.0.0.1"
+ @model_validator(mode="after")
+ def validate_security_features(self):
+ """Reject required security features that are disabled."""
+ if self.ATTESTATION_REQUIRED and not self.ATTESTATION_ENABLED:
+ raise ValueError("ATTESTATION_REQUIRED requires ATTESTATION_ENABLED")
+ return self
+
settings = Settings()
diff --git a/oid4vc/auth_server/tenant/deps.py b/oid4vc/auth_server/tenant/deps.py
index 8110ca6cf..ccface9ab 100644
--- a/oid4vc/auth_server/tenant/deps.py
+++ b/oid4vc/auth_server/tenant/deps.py
@@ -1,21 +1,29 @@
-"""Per-tenant dependencies: DB session + JWKS, cached by tenant uid via Admin API."""
+"""Tenant deps: DB session + JWKS resolution, cached per uid."""
+import re
import time
-from functools import lru_cache
+from collections import OrderedDict
from typing import AsyncIterator
import httpx
from fastapi import Depends, HTTPException, Request
from sqlalchemy import text
-from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.ext.asyncio import AsyncSession
-from core.observability.observability import current_request_id
+from core.db.cached_session import (
+ _session_factory,
+ dispose_cached_engines,
+)
+from core.observability.observability import internal_api_headers
from core.utils.retry import with_retries
from tenant.config import settings
-# In-memory cache: uid -> (timestamp, ctx)
-_CACHE: dict[str, tuple[float, dict]] = {}
+# In-memory cache: uid -> (timestamp, ctx), LRU-bounded
+_CACHE: OrderedDict[str, tuple[float, dict]] = OrderedDict()
+_MAX_CACHE = 256
_TTL = settings.CONTEXT_CACHE_TTL
+# Allowlist blocks / \ % and null — the only chars that enable URL path traversal
+_UID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,64}$")
@with_retries(
@@ -33,7 +41,7 @@
),
)
async def _get_admin_json(client: httpx.AsyncClient, url: str, headers: dict) -> dict:
- """GET JSON from Admin with retries on transient errors."""
+ """GET JSON from admin; retries configured by decorator."""
res = await client.get(url, headers=headers)
if res.status_code >= 500:
raise httpx.HTTPStatusError("server error", request=res.request, response=res)
@@ -42,21 +50,19 @@ async def _get_admin_json(client: httpx.AsyncClient, url: str, headers: dict) ->
async def _fetch_tenant_ctx(uid: str | None = None) -> dict:
- """Fetch DB and JWKS from Admin and update cache."""
+ """Fetch tenant DB + JWKS from admin, update local cache."""
if not uid:
raise HTTPException(status_code=400, detail="Missing tenant uid.")
+ if not _UID_RE.match(uid):
+ raise HTTPException(status_code=400, detail="invalid_tenant")
base = f"{settings.INTERNAL_BASE_URL}/tenants/{uid}"
- headers = {"Authorization": f"Bearer {settings.INTERNAL_AUTH_TOKEN}"}
- rid = current_request_id()
- if rid:
- headers["X-Request-ID"] = rid
+ headers = internal_api_headers(settings.INTERNAL_AUTH_TOKEN)
async with httpx.AsyncClient(timeout=10) as client:
try:
db_data = await _get_admin_json(client, f"{base}/db", headers)
except Exception as ex:
- # Fatal: cannot operate without DB coordinates for the tenant
raise HTTPException(
status_code=503, detail="admin_tenant_db_service_unavailable"
) from ex
@@ -76,18 +82,21 @@ async def _fetch_tenant_ctx(uid: str | None = None) -> dict:
"jwks": jwks_data,
}
_CACHE[uid] = (time.time(), ctx)
+ _CACHE.move_to_end(uid)
+ while len(_CACHE) > _MAX_CACHE:
+ _CACHE.popitem(last=False)
return ctx
-async def _load_tenant_ctx(request: Request, force: bool = False) -> dict:
- """Resolve tenant ctx (db + jwks) from Admin and cache by TTL."""
+async def _load_tenant_ctx(request: Request) -> dict:
+ """Load tenant context from cache or fetch from admin."""
uid: str | None = request.path_params.get("uid")
if not uid:
raise HTTPException(status_code=400, detail="tenant uid missing in path")
now = time.time()
cached = _CACHE.get(uid)
- if not force and cached is not None:
+ if cached is not None:
ts, ctx = cached
if now - ts < _TTL:
return ctx
@@ -96,7 +105,7 @@ async def _load_tenant_ctx(request: Request, force: bool = False) -> dict:
async def get_tenant_ctx(uid: str, key: str) -> dict:
- """Return a specific section of the tenant ctx ("db" or "jwks")."""
+ """Get a section of tenant context by key."""
now = time.time()
cached = _CACHE.get(uid)
if cached is not None:
@@ -114,7 +123,7 @@ async def get_tenant_ctx(uid: str, key: str) -> dict:
async def get_tenant_jwks(uid: str) -> dict:
- """Helper for `.well-known/jwks.json`."""
+ """Return normalized JWKS for a tenant."""
jwks = await get_tenant_ctx(uid, "jwks")
# Pass through if already spec-compliant; otherwise normalize sensibly
if isinstance(jwks, dict) and isinstance(jwks.get("keys"), list):
@@ -124,26 +133,34 @@ async def get_tenant_jwks(uid: str) -> dict:
return {"keys": []}
-@lru_cache(maxsize=256)
-def _sessionmaker_for(url: str, schema: str) -> async_sessionmaker[AsyncSession]:
- """Cache a sessionmaker per (url, schema)."""
- engine = create_async_engine(
+_MAX_ENGINES = 128
+
+
+def _sessionmaker_for(url: str, schema: str):
+ """Delegate to shared engine cache with tenant-sized limit."""
+ return _session_factory(
url,
- pool_pre_ping=True,
- connect_args={"server_settings": {"search_path": schema}},
+ schema,
+ max_engines=_MAX_ENGINES,
+ pool_size=settings.DB_POOL_SIZE,
+ max_overflow=settings.DB_MAX_OVERFLOW,
+ pool_recycle=settings.DB_POOL_RECYCLE,
)
- return async_sessionmaker(engine, expire_on_commit=False)
+
+
+async def dispose_engines() -> None:
+ """Shutdown hook — dispose pooled engines."""
+ await dispose_cached_engines()
async def get_db_session(
request: Request,
ctx: dict = Depends(_load_tenant_ctx),
) -> AsyncIterator[AsyncSession]:
- """FastAPI dependency to inject an AsyncSession per request."""
+ """Yield a per-request async DB session for the tenant."""
def open_session(db: dict) -> AsyncSession:
- sm = _sessionmaker_for(db["url"], db["schema"]) # cached by (url, schema)
- return sm()
+ return _sessionmaker_for(db["url"], db["schema"])()
db = ctx["db"]
session = open_session(db)
@@ -151,7 +168,7 @@ def open_session(db: dict) -> AsyncSession:
try:
await session.execute(text("SELECT 1"))
except Exception:
- # Close the broken session and refresh ctx, then retry once
+ # Stale connection — refresh ctx and retry once
await session.close()
uid = request.path_params.get("uid")
fresh = await _fetch_tenant_ctx(uid)
@@ -159,5 +176,8 @@ def open_session(db: dict) -> AsyncSession:
await session.execute(text("SELECT 1"))
# Yield exactly once
yield session
+ except Exception:
+ await session.rollback()
+ raise
finally:
await session.close()
diff --git a/oid4vc/auth_server/tenant/main.py b/oid4vc/auth_server/tenant/main.py
index 03b2bdccc..f8c4262e0 100644
--- a/oid4vc/auth_server/tenant/main.py
+++ b/oid4vc/auth_server/tenant/main.py
@@ -17,7 +17,7 @@
from core.utils.logging import get_logger
from tenant.config import settings
-from .deps import get_db_session
+from .deps import dispose_engines, get_db_session
from .routers.grants import router as grants_router
from .routers.introspect import router as introspect_router
from .routers.token import router as token_router
@@ -33,6 +33,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Startup/shutdown hooks."""
setup_structlog_json()
yield
+ await dispose_engines()
app = FastAPI(
@@ -88,5 +89,5 @@ async def log_unhandled_exception(request: Request, ex: Exception):
)
return ORJSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- content={"status": "fail", "error": f"Internal Server Error: {ex}"},
+ content={"error": "server_error", "error_description": "Internal Server Error"},
)
diff --git a/oid4vc/auth_server/tenant/models.py b/oid4vc/auth_server/tenant/models.py
index 00c3bf3db..03e427c36 100644
--- a/oid4vc/auth_server/tenant/models.py
+++ b/oid4vc/auth_server/tenant/models.py
@@ -34,16 +34,13 @@ class Subject(Base):
TIMESTAMP(timezone=True), nullable=True, onupdate=func.now()
)
pre_auth_codes: Mapped[list["PreAuthCode"]] = relationship(
- back_populates="subject", cascade="all, delete-orphan", lazy="selectin"
+ back_populates="subject", cascade="all, delete-orphan", lazy="noload"
)
access_tokens: Mapped[list["AccessToken"]] = relationship(
- back_populates="subject", cascade="all, delete-orphan", lazy="selectin"
+ back_populates="subject", cascade="all, delete-orphan", lazy="noload"
)
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
- back_populates="subject", cascade="all, delete-orphan", lazy="selectin"
- )
- dpop_jtis: Mapped[list["DpopJti"]] = relationship(
- back_populates="subject", cascade="all, delete-orphan", lazy="selectin"
+ back_populates="subject", cascade="all, delete-orphan", lazy="noload"
)
@@ -58,6 +55,9 @@ class PreAuthCode(Base):
)
code: Mapped[str] = mapped_column(Text, nullable=False)
tx_code: Mapped[str | None] = mapped_column("user_pin", Text, nullable=True)
+ tx_code_attempts: Mapped[int] = mapped_column(
+ "user_pin_attempts", Integer, nullable=False, default=0
+ )
authorization_details: Mapped[list[dict[str, Any]] | None] = mapped_column(
JSONB, nullable=True
)
@@ -119,18 +119,11 @@ class RefreshToken(Base):
)
-class DpopJti(Base):
- """DpopJti model."""
+class JtiSeen(Base):
+ """JTI replay-prevention for private_key_jwt and attestation PoP."""
- __tablename__ = "dpop_jti"
- id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
- subject_id: Mapped[int] = mapped_column(
- ForeignKey("subject.id", onupdate="CASCADE", ondelete="CASCADE"), nullable=False
- )
- jti: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
- htm: Mapped[str | None] = mapped_column(Text, nullable=True)
- htu: Mapped[str | None] = mapped_column(Text, nullable=True)
- cnf_jkt: Mapped[str | None] = mapped_column(Text, nullable=True)
- issued_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
+ __tablename__ = "jti_seen"
+
+ jti: Mapped[str] = mapped_column(Text, primary_key=True)
expires_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
- subject: Mapped["Subject"] = relationship(back_populates="dpop_jtis", lazy="joined")
+ jti_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
diff --git a/oid4vc/auth_server/tenant/oauth/grants.py b/oid4vc/auth_server/tenant/oauth/grants.py
index b122540a6..63a74fddc 100644
--- a/oid4vc/auth_server/tenant/oauth/grants.py
+++ b/oid4vc/auth_server/tenant/oauth/grants.py
@@ -5,10 +5,16 @@
from authlib.oauth2.rfc6749 import grants
from authlib.oauth2.rfc6749.errors import InvalidRequestError
+from fastapi import HTTPException as FastAPIHTTPException
from starlette.requests import Request
from core.consts import OAuth2Flow, OAuth2GrantType
+from tenant.config import settings
from tenant.oauth.integration.context import get_context, update_context
+from tenant.services.attestation_service import (
+ AttestationLookupError,
+ validate_client_attestation,
+)
class _BaseTenantGrant(grants.BaseGrant):
@@ -16,20 +22,72 @@ class _BaseTenantGrant(grants.BaseGrant):
TOKEN_ENDPOINT_AUTH_METHODS = ["none"]
- async def authenticate_token_endpoint_client(self): # type: ignore[override]
+ async def authenticate_token_endpoint_client(self):
"""Bypass client authentication."""
return None
request: Request
+ def _resolve_uid(self) -> str:
+ """Resolve tenant UID from request context, falling back to the URL path."""
+ extra = get_context(self.request)
+ uid = getattr(extra, "uid", None)
+ if not uid:
+ url = getattr(self.request, "uri", None) or getattr(self.request, "url", "")
+ path = urlparse(url).path if url else ""
+ parts = [p for p in path.split("/") if p]
+ try:
+ tidx = parts.index("tenants")
+ uid = parts[tidx + 1]
+ except Exception:
+ uid = None
+ if not uid:
+ raise InvalidRequestError(description="missing_tenant_uid")
+ return uid
+
+ def _resolve_tenant(self) -> tuple[str, Any]:
+ """Extract tenant UID and DB session from request context.
+
+ Returns (uid, db). Raises InvalidRequestError on failure.
+ """
+ uid = self._resolve_uid()
+ db = getattr(get_context(self.request), "db", None)
+ if db is None:
+ raise InvalidRequestError(description="server_error")
+ return uid, db
+
+ async def _validate_attestation(self, *, required: bool) -> dict[str, Any] | None:
+ """Extract attestation headers and validate against allow list."""
+ if not settings.ATTESTATION_ENABLED:
+ return None
+ headers = getattr(self.request, "headers", {}) or {}
+ extra = get_context(self.request)
+ # Audience pinning requires the tenant, so resolve it before validating.
+ uid = self._resolve_uid()
+ try:
+ return await validate_client_attestation(
+ client_attestation=headers.get("oauth-client-attestation") or None,
+ client_attestation_pop=headers.get("oauth-client-attestation-pop")
+ or None,
+ attestation_required=required,
+ expected_audience=f"{settings.ISSUER_BASE_URL}/tenants/{uid}",
+ db=getattr(extra, "db", None),
+ )
+ except AttestationLookupError as ex:
+ # Provider trust could not be evaluated; never report as client error.
+ raise FastAPIHTTPException(
+ status_code=503, detail="attestation_provider_unavailable"
+ ) from ex
+
class PreAuthorizedCodeGrant(_BaseTenantGrant):
"""OID4VCI pre-authorized_code grant."""
_code: str | None = None
_tx_code: str | None = None
+ _attestation_meta: dict[str, Any] | None = None
- async def validate_token_request(self): # type: ignore[override]
+ async def validate_token_request(self):
"""Validate pre-authorized_code request."""
payload = getattr(self.request, "payload", None)
data = getattr(payload, "data", {}) if payload is not None else {}
@@ -38,25 +96,13 @@ async def validate_token_request(self): # type: ignore[override]
raise InvalidRequestError(description="missing pre-authorized_code")
self._code = str(code)
self._tx_code = data.get("tx_code") or None
+ self._attestation_meta = await self._validate_attestation(
+ required=settings.ATTESTATION_REQUIRED
+ )
- async def create_token_response(self): # type: ignore[override]
+ async def create_token_response(self):
"""Create token response for pre-authorized_code."""
- extra = get_context(self.request)
- uid = getattr(extra, "uid", None)
- if not uid:
- url = getattr(self.request, "uri", None) or getattr(self.request, "url", "")
- path = urlparse(url).path if url else ""
- parts = [p for p in path.split("/") if p]
- try:
- tidx = parts.index("tenants")
- uid = parts[tidx + 1]
- except Exception:
- uid = None
- if not uid:
- raise InvalidRequestError(description="missing_tenant_uid")
- db = getattr(extra, "db", None)
- if db is None:
- raise InvalidRequestError(description="server_error")
+ uid, db = self._resolve_tenant()
# Stash context for save_token
update_context(
self.request,
@@ -65,6 +111,7 @@ async def create_token_response(self): # type: ignore[override]
"uid": uid,
"code": self._code or "",
"tx_code": self._tx_code,
+ "attestation": self._attestation_meta,
"realm": uid,
},
)
@@ -74,7 +121,7 @@ async def create_token_response(self): # type: ignore[override]
return 200, token_data, []
@classmethod
- def check_token_endpoint(cls, request) -> bool: # type: ignore[override]
+ def check_token_endpoint(cls, request) -> bool:
"""Return True when request payload grant_type matches."""
try:
payload = getattr(request, "payload", None)
@@ -88,8 +135,9 @@ class RotatingRefreshTokenGrant(_BaseTenantGrant):
"""Refresh token grant with rotation."""
_refresh_token: str | None = None
+ _attestation_meta: dict[str, Any] | None = None
- async def validate_token_request(self): # type: ignore[override]
+ async def validate_token_request(self):
"""Validate refresh_token request."""
payload = getattr(self.request, "payload", None)
data = getattr(payload, "data", {}) if payload is not None else {}
@@ -97,25 +145,13 @@ async def validate_token_request(self): # type: ignore[override]
if not refresh_token:
raise InvalidRequestError(description="missing refresh_token")
self._refresh_token = str(refresh_token)
+ self._attestation_meta = await self._validate_attestation(
+ required=settings.ATTESTATION_REQUIRED
+ )
- async def create_token_response(self): # type: ignore[override]
+ async def create_token_response(self):
"""Create token response for refresh_token."""
- extra = get_context(self.request)
- uid = getattr(extra, "uid", None)
- if not uid:
- url = getattr(self.request, "uri", None) or getattr(self.request, "url", "")
- path = urlparse(url).path if url else ""
- parts = [p for p in path.split("/") if p]
- try:
- tidx = parts.index("tenants")
- uid = parts[tidx + 1]
- except Exception:
- uid = None
- if not uid:
- raise InvalidRequestError(description="missing_tenant_uid")
- db = getattr(extra, "db", None)
- if db is None:
- raise InvalidRequestError(description="server_error")
+ uid, db = self._resolve_tenant()
# Stash context for save_token
update_context(
self.request,
@@ -123,6 +159,7 @@ async def create_token_response(self): # type: ignore[override]
"flow": OAuth2Flow.REFRESH_TOKEN,
"uid": uid,
"refresh_token": self._refresh_token or "",
+ "attestation": self._attestation_meta,
"realm": uid,
},
)
@@ -132,7 +169,7 @@ async def create_token_response(self): # type: ignore[override]
return 200, token_data, []
@classmethod
- def check_token_endpoint(cls, request) -> bool: # type: ignore[override]
+ def check_token_endpoint(cls, request) -> bool:
"""Return True when request payload grant_type is refresh_token."""
try:
payload = getattr(request, "payload", None)
diff --git a/oid4vc/auth_server/tenant/oauth/server.py b/oid4vc/auth_server/tenant/oauth/server.py
index be524ae37..7252af399 100644
--- a/oid4vc/auth_server/tenant/oauth/server.py
+++ b/oid4vc/auth_server/tenant/oauth/server.py
@@ -2,6 +2,7 @@
from typing import Any
+import structlog
from authlib.oauth2.rfc6749 import AuthorizationServer
from authlib.oauth2.rfc6749.errors import (
InvalidGrantError,
@@ -15,6 +16,8 @@
from tenant.oauth.integration.server import CoreAuthorizationServer
from tenant.services.token_service import TokenService
+logger = structlog.get_logger(__name__)
+
_server: AuthorizationServer | None = None
@@ -27,6 +30,33 @@ def get_authorization_server() -> AuthorizationServer:
async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover
"""Persist tokens based on flow context and finalize payload."""
+
+ def _fill_response(
+ access_token_obj: Any,
+ refresh_token_val: str,
+ response_meta: dict[str, Any],
+ ) -> None:
+ token.update(
+ {
+ "access_token": access_token_obj.token,
+ "refresh_token": refresh_token_val,
+ "token_type": "Bearer",
+ "expires_in": int(
+ (
+ access_token_obj.expires_at - access_token_obj.issued_at
+ ).total_seconds()
+ ),
+ }
+ )
+ if response_meta.get("authorization_details"):
+ token["authorization_details"] = response_meta["authorization_details"]
+ if response_meta.get("c_nonce"):
+ token["c_nonce"] = response_meta["c_nonce"]
+ if response_meta.get("c_nonce_expires_in"):
+ token["c_nonce_expires_in"] = int(response_meta["c_nonce_expires_in"])
+ if response_meta.get("amr"):
+ token["amr"] = response_meta["amr"]
+
extra = get_context(request)
ctx = getattr(extra, "token_ctx", None) or {}
uid = getattr(extra, "uid", None)
@@ -41,6 +71,7 @@ async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover
if flow == OAuth2Flow.PRE_AUTH_CODE:
code = ctx.get("code") or ""
tx_code = ctx.get("tx_code")
+ attestation = ctx.get("attestation")
(
access_token,
refresh_token,
@@ -51,31 +82,14 @@ async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover
code=code,
realm=realm,
tx_code=tx_code,
+ attestation=attestation,
)
- token.update(
- {
- "access_token": access_token.token,
- "refresh_token": refresh_token,
- "token_type": "Bearer",
- "expires_in": int(
- (
- access_token.expires_at - access_token.issued_at
- ).total_seconds()
- ),
- }
- )
- if response_meta.get("authorization_details"):
- token["authorization_details"] = response_meta[
- "authorization_details"
- ]
- if response_meta.get("c_nonce"):
- token["c_nonce"] = response_meta["c_nonce"]
- if response_meta.get("c_nonce_expires_in"):
- token["c_nonce_expires_in"] = int(response_meta["c_nonce_expires_in"])
+ _fill_response(access_token, refresh_token, response_meta)
return
if flow == OAuth2Flow.REFRESH_TOKEN:
refresh_token = ctx.get("refresh_token") or ""
+ attestation = ctx.get("attestation")
(
new_access,
new_refresh_token,
@@ -85,36 +99,27 @@ async def _save_token(token: dict[str, Any], request: Any): # pragma: no cover
uid=uid,
refresh_token_value=refresh_token,
realm=realm,
+ attestation=attestation,
)
- token.update(
- {
- "access_token": new_access.token,
- "refresh_token": new_refresh_token,
- "token_type": "Bearer",
- "expires_in": int(
- (new_access.expires_at - new_access.issued_at).total_seconds()
- ),
- }
- )
- if response_meta.get("authorization_details"):
- token["authorization_details"] = response_meta[
- "authorization_details"
- ]
- if response_meta.get("c_nonce"):
- token["c_nonce"] = response_meta["c_nonce"]
- if response_meta.get("c_nonce_expires_in"):
- token["c_nonce_expires_in"] = int(response_meta["c_nonce_expires_in"])
+ _fill_response(new_access, new_refresh_token, response_meta)
return
except FastAPIHTTPException as e: # map service errors to OAuth errors
+ detail = getattr(e, "detail", None) or "unknown"
+ logger.warning(
+ "token_service_error",
+ status_code=e.status_code,
+ detail=detail,
+ flow=flow,
+ )
if e.status_code == 400:
- detail = getattr(e, "detail", None)
if detail == "invalid_grant":
raise InvalidGrantError(description="invalid_grant")
- raise InvalidRequestError(description="invalid_request")
+ raise InvalidRequestError(description=detail)
if e.status_code == 401:
raise InvalidGrantError(description="invalid_grant")
raise InvalidRequestError(description="server_error")
except Exception:
+ logger.exception("token_save_unexpected_error", flow=flow)
raise InvalidRequestError(description="server_error")
raise InvalidRequestError(description="unknown_token_flow")
diff --git a/oid4vc/auth_server/tenant/repositories/access_token_repository.py b/oid4vc/auth_server/tenant/repositories/access_token_repository.py
index 6d136e65c..471e59a50 100644
--- a/oid4vc/auth_server/tenant/repositories/access_token_repository.py
+++ b/oid4vc/auth_server/tenant/repositories/access_token_repository.py
@@ -1,29 +1,18 @@
"""AccessToken repository."""
-from datetime import datetime, timezone
+from datetime import datetime
from typing import Union
from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
from tenant.models import AccessToken
+from tenant.repositories.base import BaseTokenRepository
-class AccessTokenRepository:
+class AccessTokenRepository(BaseTokenRepository):
"""Repository for access tokens."""
- def __init__(self, db: AsyncSession):
- """Constructor."""
- self.db = db
-
- @staticmethod
- def _to_dt(value: Union[int, float, datetime]) -> datetime:
- """Normalize epoch seconds or datetime to UTC datetime."""
- if isinstance(value, datetime):
- return (
- value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
- )
- return datetime.fromtimestamp(float(value), tz=timezone.utc)
+ _model = AccessToken
async def create(
self,
@@ -32,6 +21,7 @@ async def create(
issued_at: Union[int, float, datetime],
expires_at: Union[int, float, datetime],
token_metadata: dict | None = None,
+ cnf_jkt: str | None = None,
) -> AccessToken:
"""Create and add a new access token."""
issued_dt = self._to_dt(issued_at)
@@ -42,6 +32,7 @@ async def create(
issued_at=issued_dt,
expires_at=expires_dt,
token_metadata=token_metadata or {},
+ cnf_jkt=cnf_jkt,
)
self.db.add(access_token)
await self.db.flush()
diff --git a/oid4vc/auth_server/tenant/repositories/base.py b/oid4vc/auth_server/tenant/repositories/base.py
new file mode 100644
index 000000000..33d6a5c95
--- /dev/null
+++ b/oid4vc/auth_server/tenant/repositories/base.py
@@ -0,0 +1,40 @@
+"""Base repository for token models with shared behavior."""
+
+from datetime import datetime, timezone
+from typing import Any, Union
+
+from sqlalchemy import update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class BaseTokenRepository:
+ """Base for token repositories that have subject_id + revoked columns."""
+
+ _model: Any = None # Subclasses set this to their SQLAlchemy model
+
+ def __init__(self, db: AsyncSession):
+ """Constructor."""
+ self.db = db
+
+ @staticmethod
+ def _to_dt(value: Union[int, float, datetime]) -> datetime:
+ """Normalize epoch seconds or datetime to UTC datetime."""
+ if isinstance(value, datetime):
+ return (
+ value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
+ )
+ return datetime.fromtimestamp(float(value), tz=timezone.utc)
+
+ async def revoke_all_for_subject(self, subject_id: int) -> int:
+ """Revoke all tokens for a subject (breach response)."""
+ model = self._model
+ stmt = (
+ update(model)
+ .where(
+ model.subject_id == subject_id,
+ model.revoked.is_(False),
+ )
+ .values(revoked=True)
+ )
+ res = await self.db.execute(stmt)
+ return res.rowcount
diff --git a/oid4vc/auth_server/tenant/repositories/grant_repository.py b/oid4vc/auth_server/tenant/repositories/grant_repository.py
index 40fccfa3e..d0e045ec7 100644
--- a/oid4vc/auth_server/tenant/repositories/grant_repository.py
+++ b/oid4vc/auth_server/tenant/repositories/grant_repository.py
@@ -45,6 +45,30 @@ async def consume_valid(self, pac_id: int, now) -> bool:
res = await self.db.execute(stmt)
return bool(res.rowcount and res.rowcount > 0)
+ async def increment_tx_code_attempts(self, pac_id: int, max_attempts: int) -> int:
+ """Bump attempt counter; burns the code if limit hit. Returns new count."""
+ stmt = (
+ update(PreAuthCode)
+ .where(
+ PreAuthCode.id == pac_id,
+ PreAuthCode.used.is_(False),
+ )
+ .values(tx_code_attempts=PreAuthCode.tx_code_attempts + 1)
+ .returning(PreAuthCode.tx_code_attempts)
+ )
+ res = await self.db.execute(stmt)
+ new_count = res.scalar_one_or_none()
+ if new_count is None:
+ return max_attempts # already consumed
+ if new_count >= max_attempts:
+ consume_stmt = (
+ update(PreAuthCode)
+ .where(PreAuthCode.id == pac_id, PreAuthCode.used.is_(False))
+ .values(used=True)
+ )
+ await self.db.execute(consume_stmt)
+ return new_count
+
async def create_pre_auth_code(
self,
*,
diff --git a/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py b/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py
index f1f05c48c..0f6bfa579 100644
--- a/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py
+++ b/oid4vc/auth_server/tenant/repositories/refresh_token_repository.py
@@ -1,28 +1,18 @@
"""RefreshToken repository."""
-from datetime import datetime, timezone
from typing import Union
+from datetime import datetime
-from sqlalchemy import update
-from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, update
from tenant.models import RefreshToken
+from tenant.repositories.base import BaseTokenRepository
-class RefreshTokenRepository:
+class RefreshTokenRepository(BaseTokenRepository):
"""Repository for refresh tokens."""
- def __init__(self, db: AsyncSession):
- """Constructor."""
- self.db = db
-
- @staticmethod
- def _to_dt(value: Union[int, float, datetime]) -> datetime:
- if isinstance(value, datetime):
- return (
- value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
- )
- return datetime.fromtimestamp(float(value), tz=timezone.utc)
+ _model = RefreshToken
async def create(
self,
@@ -64,3 +54,17 @@ async def consume_valid(self, token_hash: str, now) -> tuple[int, int] | None:
res = await self.db.execute(stmt)
row = res.first()
return tuple(row) if row is not None else None
+
+ async def is_token_reuse(self, token_hash: str) -> int | None:
+ """Check if a refresh token was already consumed (reuse detection).
+
+ Returns the subject_id if the token exists and is already used,
+ None otherwise (token doesn't exist or was never consumed).
+ """
+ stmt = select(RefreshToken.subject_id).where(
+ RefreshToken.token_hash == token_hash,
+ RefreshToken.used.is_(True),
+ )
+ res = await self.db.execute(stmt)
+ row = res.first()
+ return row[0] if row is not None else None
diff --git a/oid4vc/auth_server/tenant/routers/introspect.py b/oid4vc/auth_server/tenant/routers/introspect.py
index 6737fcea5..07cfe0782 100644
--- a/oid4vc/auth_server/tenant/routers/introspect.py
+++ b/oid4vc/auth_server/tenant/routers/introspect.py
@@ -25,4 +25,8 @@ async def introspect(
):
"""Return RFC 7662-style token introspection payload."""
data = await introspect_access_token(db, uid, token)
- return ORJSONResponse(data, status_code=200)
+ return ORJSONResponse(
+ data,
+ status_code=200,
+ headers={"Cache-Control": "no-store", "Pragma": "no-cache"},
+ )
diff --git a/oid4vc/auth_server/tenant/routers/token.py b/oid4vc/auth_server/tenant/routers/token.py
index f22568517..06eda2d95 100644
--- a/oid4vc/auth_server/tenant/routers/token.py
+++ b/oid4vc/auth_server/tenant/routers/token.py
@@ -20,15 +20,20 @@ async def token_endpoint(
description="Grant type",
enum=["urn:ietf:params:oauth:grant-type:pre-authorized_code", "refresh_token"],
),
- pre_authorized_code: str | None = Form(None, alias="pre-authorized_code"),
- pre_authorized_code_alt: str | None = Form(None, alias="pre_authorized_code"),
+ pre_authorized_code: str | None = Form(None),
tx_code: str | None = Form(None),
refresh_token: str | None = Form(None),
db: AsyncSession = Depends(get_db_session),
):
"""Delegate token issuance to Authlib AuthorizationServer with custom grants."""
- pac_value = pre_authorized_code or pre_authorized_code_alt
+ # Real OID4VCI wallets send "pre-authorized_code" (hyphen); Swagger sends
+ # "pre_authorized_code" (underscore). Accept both.
+ pac_value = pre_authorized_code
+ if not pac_value:
+ raw_form = await request.form()
+ pac_value = raw_form.get("pre-authorized_code") or None # type: ignore[assignment]
+
form_data = {
"grant_type": grant_type,
"pre-authorized_code": pac_value,
@@ -40,4 +45,7 @@ async def token_endpoint(
server = get_authorization_server()
status_code, body, headers = await server.create_token_response_async(oauth2_req) # type: ignore[attr-defined]
- return ORJSONResponse(body, status_code=status_code, headers=dict(headers))
+ resp_headers = dict(headers)
+ resp_headers["Cache-Control"] = "no-store"
+ resp_headers["Pragma"] = "no-cache"
+ return ORJSONResponse(body, status_code=status_code, headers=resp_headers)
diff --git a/oid4vc/auth_server/tenant/routers/well_known.py b/oid4vc/auth_server/tenant/routers/well_known.py
index 4f5325ac6..060ddf62e 100644
--- a/oid4vc/auth_server/tenant/routers/well_known.py
+++ b/oid4vc/auth_server/tenant/routers/well_known.py
@@ -1,6 +1,6 @@
"""OIDC discovery and JWKS endpoints (thin router)."""
-from fastapi import APIRouter, Path, Request, Response
+from fastapi import APIRouter, HTTPException, Path, Request, Response
from fastapi.responses import ORJSONResponse
from tenant.config import settings
@@ -21,9 +21,12 @@ async def openid_configuration(
request: Request, response: Response, uid: str = Path(...)
):
"""Return OIDC discovery for the tenant."""
- payload = build_oauth_auth_server(uid, request)
+ try:
+ payload = await build_oauth_auth_server(uid, request)
+ except HTTPException:
+ raise HTTPException(status_code=404, detail="tenant_not_found")
ttl = settings.CONTEXT_CACHE_TTL
- response.headers["Cache-Control"] = f"public, max-age={ttl}"
+ response.headers["Cache-Control"] = f"private, max-age={ttl}"
return payload
@@ -38,7 +41,10 @@ async def jwks(
uid: str = Path(...),
):
"""Return JWKS (RFC 7517) for the tenant."""
- keys = await load_tenant_jwks(uid)
+ try:
+ keys = await load_tenant_jwks(uid)
+ except HTTPException:
+ raise HTTPException(status_code=404, detail="tenant_not_found")
ttl = settings.CONTEXT_CACHE_TTL
response.headers["Cache-Control"] = f"public, max-age={ttl}"
return keys
diff --git a/oid4vc/auth_server/tenant/security/client_auth.py b/oid4vc/auth_server/tenant/security/client_auth.py
index c14ddb2da..bd1cea791 100644
--- a/oid4vc/auth_server/tenant/security/client_auth.py
+++ b/oid4vc/auth_server/tenant/security/client_auth.py
@@ -1,28 +1,271 @@
-"""Tenant client authentication dependency."""
+"""Tenant client authentication (private_key_jwt, client_secret_basic)."""
-from fastapi import Depends, Request, Security
+import json
+from typing import Any, Mapping
+
+from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import (
HTTPAuthorizationCredentials,
HTTPBasic,
HTTPBasicCredentials,
HTTPBearer,
)
+from joserfc import jwt
+from joserfc.jwk import KeySet
from sqlalchemy.ext.asyncio import AsyncSession
+from core.consts import CLIENT_AUTH_METHODS, SUPPORTED_SIGNING_ALGS
+from core.consts import ClientAuthMethod as CLIENT_AUTH_METHOD
+from core.crypto.crypto import verify_secret_pbkdf2
from core.models import Client as AuthClient
-from core.security.client_auth import base_client_auth
+from core.repositories.client_repository import ClientRepository
+from core.security.jwks_cache import JWKSCache
+from core.security.utils import jwt_header_unverified, jwt_payload_unverified
+from core.utils.logging import get_logger
from tenant.deps import get_db_session
+from tenant.security.jti_cache import JtiCache
+
+logger = get_logger(__name__)
+
+_pkjwt_jti_cache = JtiCache(window=300)
+_jwks_uri_cache = JWKSCache(ttl=300)
basic_security = HTTPBasic(auto_error=False)
bearer_security = HTTPBearer(auto_error=False)
+async def _load_jwks(client, kid: str | None = None) -> KeySet | None:
+ jwks_data: dict | None = None
+ if isinstance(client.jwks, dict):
+ jwks_data = client.jwks
+ elif client.jwks and isinstance(client.jwks, str):
+ try:
+ jwks_data = json.loads(client.jwks)
+ except Exception:
+ return None
+ if jwks_data:
+ return KeySet.import_key_set(jwks_data)
+ if client.jwks_uri:
+ return await _jwks_uri_cache.get_jwks(client.jwks_uri, kid=kid)
+ return None
+
+
+def _audiences_for(request: Request) -> list[str]:
+ # Full URL without query
+ url = str(request.url)
+ base = url.split("?", 1)[0]
+ return [base]
+
+
+def _validate_jwt_alg(token: str, expected_alg: str):
+ """Validate the 'alg' field in the JWT header."""
+ header = jwt_header_unverified(token)
+ if header.get("alg") != expected_alg:
+ raise HTTPException(status_code=401, detail="invalid_alg")
+
+
+def _validate_jwt_claims(decoded: dict[str, Any], request: Request):
+ """Validate standard JWT claims."""
+ for claim in ("iss", "sub", "aud", "exp", "iat", "jti"):
+ if claim not in decoded:
+ raise HTTPException(status_code=401, detail=f"missing_{claim}")
+ aud = decoded.get("aud")
+ expected_aud = _audiences_for(request)
+ if isinstance(aud, str):
+ aud = [aud]
+ if not aud or not any(a in expected_aud for a in aud):
+ raise HTTPException(status_code=401, detail="invalid_audience")
+
+
+async def _decode_and_validate_jwt(
+ token: str,
+ key_material: Any,
+ request: Request,
+ db: AsyncSession,
+ expected_alg: str | None = None,
+) -> Mapping[str, Any]:
+ """Decode, validate, and return JWT claims."""
+
+ if expected_alg:
+ _validate_jwt_alg(token, expected_alg)
+ algorithms = [expected_alg]
+ else:
+ # No client-level restriction; still limit to asymmetric algs
+ header = jwt_header_unverified(token)
+ alg = header.get("alg")
+ if alg not in SUPPORTED_SIGNING_ALGS:
+ raise HTTPException(status_code=401, detail="unsupported_alg")
+ algorithms = [alg]
+
+ try:
+ result = jwt.decode(token, key_material, algorithms=algorithms) # type: ignore[arg-type]
+ claims = result.claims
+ claims_registry = jwt.JWTClaimsRegistry()
+ claims_registry.validate(claims, leeway=30)
+ _validate_jwt_claims(claims, request)
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.warning("client_assertion decode/validate failed: %s", exc)
+ raise HTTPException(status_code=401, detail="invalid_client") from exc
+
+ if not isinstance(claims, Mapping):
+ logger.warning("JWT claims is not a mapping")
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ # Replay check (RFC 7523 §3) — store until JWT expires
+ jti = claims.get("jti")
+ exp = claims.get("exp")
+ if not await _pkjwt_jti_cache.check_and_store(jti, db, expires_at=exp):
+ logger.warning("replayed jti=%s for sub=%s", jti, claims.get("sub"))
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ return claims
+
+
+async def _authenticate_private_key_jwt(
+ client: AuthClient,
+ token: str,
+ request: Request,
+ db: AsyncSession,
+) -> Mapping[str, Any]:
+ """Validate private_key_jwt assertions."""
+
+ header = jwt_header_unverified(token)
+ kid = header.get("kid")
+ keys = await _load_jwks(client, kid=kid)
+ if not keys or not keys.keys:
+ logger.warning("no keys found for client=%s kid=%s", client.client_id, kid)
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ claims = await _decode_and_validate_jwt(
+ token,
+ keys,
+ request,
+ db,
+ expected_alg=client.client_auth_signing_alg,
+ )
+
+ # iss and sub must match client_id (RFC 7523 §3)
+ if str(claims.get("iss")) != str(client.client_id):
+ logger.warning(
+ "iss mismatch: got %s, expected %s",
+ claims.get("iss"),
+ client.client_id,
+ )
+ raise HTTPException(status_code=401, detail="invalid_client")
+ if str(claims.get("sub")) != str(client.client_id):
+ logger.warning(
+ "sub mismatch: got %s, expected %s",
+ claims.get("sub"),
+ client.client_id,
+ )
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ return claims
+
+
+def _authenticate_client_secret_basic(client: AuthClient, token: str) -> None:
+ """Validate client_secret_basic credentials."""
+
+ secret_hash = client.client_secret
+ if secret_hash and token and verify_secret_pbkdf2(token, secret_hash):
+ return
+ logger.warning(
+ "secret mismatch for client=%s",
+ getattr(client, "client_id", "?"),
+ )
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+
+async def _base_client_auth(
+ db: AsyncSession,
+ request: Request,
+ basic_creds: HTTPBasicCredentials | None = None,
+ credentials: HTTPAuthorizationCredentials | None = None,
+) -> AuthClient:
+ """Authenticate client and return the persisted Client model."""
+ client_id: str | None = None
+ token: str | None = None
+
+ scheme = credentials.scheme.lower() if credentials and credentials.scheme else ""
+ cred = credentials.credentials if credentials else ""
+
+ if scheme == "bearer" and cred:
+ token = cred
+ try:
+ claims = jwt_payload_unverified(token) or {}
+ client_id = claims.get("sub")
+ except Exception as ex:
+ logger.exception("Failed to decode bearer token: %s", ex)
+ raise HTTPException(status_code=401, detail="invalid_client_assertion")
+ elif basic_creds and basic_creds.username is not None:
+ client_id = basic_creds.username
+ token = basic_creds.password or ""
+ scheme = "basic"
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="unauthorized",
+ headers={"WWW-Authenticate": "Bearer, Basic"},
+ )
+
+ if not client_id or not token:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="unauthorized",
+ headers={"WWW-Authenticate": "Bearer, Basic"},
+ )
+
+ repo = ClientRepository(db)
+ client = await repo.get_by_client_id(str(client_id))
+ if client is None:
+ logger.warning("unknown client_id=%s", client_id)
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ allowed = (client.client_auth_method or "").lower()
+ if allowed not in set(CLIENT_AUTH_METHODS):
+ logger.warning(
+ "unsupported auth method=%s for client=%s",
+ allowed,
+ client.client_id,
+ )
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC and scheme != "basic":
+ logger.warning(
+ "client=%s requires basic auth, got scheme=%s",
+ client.client_id,
+ scheme,
+ )
+ raise HTTPException(status_code=401, detail="invalid_client")
+ if allowed == CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT and scheme != "bearer":
+ logger.warning(
+ "client=%s requires private_key_jwt, got scheme=%s",
+ client.client_id,
+ scheme,
+ )
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+ if allowed == CLIENT_AUTH_METHOD.PRIVATE_KEY_JWT:
+ await _authenticate_private_key_jwt(client, token, request, db)
+ request.state.client_id = str(client.client_id)
+ return client
+
+ if allowed == CLIENT_AUTH_METHOD.CLIENT_SECRET_BASIC:
+ _authenticate_client_secret_basic(client, token)
+ request.state.client_id = str(client.client_id)
+ return client
+
+ raise HTTPException(status_code=401, detail="invalid_client")
+
+
async def client_auth(
request: Request,
basic_creds: HTTPBasicCredentials | None = Security(basic_security),
credentials: HTTPAuthorizationCredentials | None = Security(bearer_security),
db: AsyncSession = Depends(get_db_session),
) -> AuthClient:
- """Authenticate client and return the persisted Client model for tenant context."""
+ """FastAPI dependency: authenticate client for tenant endpoints."""
- return await base_client_auth(db, request, basic_creds, credentials)
+ return await _base_client_auth(db, request, basic_creds, credentials)
diff --git a/oid4vc/auth_server/tenant/security/jti_cache.py b/oid4vc/auth_server/tenant/security/jti_cache.py
new file mode 100644
index 000000000..ebc635f61
--- /dev/null
+++ b/oid4vc/auth_server/tenant/security/jti_cache.py
@@ -0,0 +1,61 @@
+"""JTI replay prevention (PostgreSQL-backed).
+
+Covers: private_key_jwt (RFC 7523), attestation PoP (draft-07).
+"""
+
+from __future__ import annotations
+
+import time
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+from sqlalchemy.dialects.postgresql import insert
+
+from tenant.models import JtiSeen
+
+if TYPE_CHECKING:
+ from sqlalchemy.ext.asyncio import AsyncSession
+
+
+class JtiCache:
+ """INSERT ON CONFLICT dedup for JTI values."""
+
+ def __init__(self, *, window: int | float = 300):
+ """Window = default expiry if caller doesn't provide one."""
+ self._window = window
+
+ async def check_and_store(
+ self,
+ jti: str | None,
+ db: AsyncSession,
+ now: float | None = None,
+ expires_at: float | None = None,
+ metadata: dict | None = None,
+ ) -> bool:
+ """Return True if fresh (stored); False if missing or replayed."""
+ if not jti:
+ return False
+ if now is None:
+ now = time.time()
+
+ exp_ts = expires_at if expires_at is not None else now + self._window
+ exp_dt = datetime.fromtimestamp(exp_ts, tz=timezone.utc)
+ now_dt = datetime.fromtimestamp(now, tz=timezone.utc)
+
+ # Conflict update only fires if existing row is already expired.
+ stmt = (
+ insert(JtiSeen)
+ .values(jti=jti, expires_at=exp_dt, jti_metadata=metadata)
+ .on_conflict_do_update(
+ index_elements=[JtiSeen.jti],
+ set_={
+ JtiSeen.expires_at: exp_dt,
+ JtiSeen.jti_metadata: metadata,
+ },
+ where=JtiSeen.expires_at <= now_dt,
+ )
+ .returning(JtiSeen.jti)
+ )
+ result = await db.execute(stmt)
+ row = result.first()
+ return row is not None
diff --git a/oid4vc/auth_server/tenant/security/token.py b/oid4vc/auth_server/tenant/security/token.py
new file mode 100644
index 000000000..e024e96e7
--- /dev/null
+++ b/oid4vc/auth_server/tenant/security/token.py
@@ -0,0 +1,24 @@
+"""Tenant token lifecycle helpers."""
+
+import secrets
+from datetime import datetime, timedelta
+
+from core.security.utils import utcnow
+from tenant.config import settings
+
+
+def new_refresh_token() -> str:
+ """Opaque refresh token."""
+ return secrets.token_urlsafe(settings.TOKEN_BYTES)
+
+
+def compute_access_exp(now: datetime | None = None) -> datetime:
+ """Access token expiry from now."""
+ now = now or utcnow()
+ return now + timedelta(seconds=settings.ACCESS_TOKEN_TTL)
+
+
+def compute_refresh_exp(now: datetime | None = None) -> datetime:
+ """Refresh token expiry from now."""
+ now = now or utcnow()
+ return now + timedelta(seconds=settings.REFRESH_TOKEN_TTL)
diff --git a/oid4vc/auth_server/tenant/services/attestation_service.py b/oid4vc/auth_server/tenant/services/attestation_service.py
new file mode 100644
index 000000000..dd5d14c17
--- /dev/null
+++ b/oid4vc/auth_server/tenant/services/attestation_service.py
@@ -0,0 +1,491 @@
+"""Client attestation validation for tenant token flow.
+
+Verifies attestation JWTs signed by trusted Wallet Providers.
+Provider public keys are looked up from the admin allow list via internal API.
+"""
+
+import json
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+from authlib.oauth2.rfc6749.errors import InvalidRequestError
+from joserfc import jwk, jwt
+from joserfc.jws import extract_compact
+
+from core.observability.observability import internal_api_headers
+from core.consts import SUPPORTED_SIGNING_ALGS
+from tenant.security.jti_cache import JtiCache
+from core.utils.logging import get_logger
+from tenant.config import settings
+
+logger = get_logger(__name__)
+
+
+# --- Attestation PoP jti replay cache (draft-07 §9 step 10) ---
+_attest_pop_jti_cache = JtiCache()
+
+
+def _jti_window() -> int:
+ """Replay window for PoP jti values, read live so overrides take effect."""
+ return int(settings.ATTESTATION_CLOCK_SKEW_SECONDS) * 2
+
+
+class InvalidAttestationError(InvalidRequestError):
+ """OAuth error for invalid client attestation."""
+
+ error = "invalid_client_attestation"
+
+
+class AttestationLookupError(Exception):
+ """Provider lookup could not be completed; not a client error."""
+
+
+def _jwt_extract(jwt_token: str) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Extract JWT header and payload without signature verification."""
+ try:
+ obj = extract_compact(jwt_token.encode())
+ header = obj.headers()
+ payload = json.loads(obj.payload)
+ except Exception as ex:
+ logger.debug(
+ "Attestation JWT extract failed: %s (token_len=%d)",
+ ex,
+ len(jwt_token),
+ exc_info=True,
+ )
+ raise InvalidAttestationError(description="malformed_attestation") from ex
+ if not isinstance(header, dict) or not isinstance(payload, dict):
+ logger.debug(
+ "Attestation JWT extract produced non-dict parts: "
+ "header_type=%s payload_type=%s",
+ type(header).__name__,
+ type(payload).__name__,
+ )
+ raise InvalidAttestationError(description="malformed_attestation")
+ return header, payload
+
+
+def _now_ts() -> int:
+ return int(datetime.now(timezone.utc).timestamp())
+
+
+def _required_claim_str(claims: dict[str, Any], name: str) -> str:
+ value = claims.get(name)
+ if not isinstance(value, str) or not value:
+ logger.debug(
+ "Attestation claim %r missing/invalid: type=%s",
+ name,
+ type(value).__name__,
+ )
+ raise InvalidAttestationError(description=f"missing_{name}")
+ return value
+
+
+def _required_claim_int(claims: dict[str, Any], name: str) -> int:
+ value = claims.get(name)
+ if not isinstance(value, int):
+ logger.debug(
+ "Attestation claim %r missing/invalid: type=%s",
+ name,
+ type(value).__name__,
+ )
+ raise InvalidAttestationError(description=f"missing_{name}")
+ return value
+
+
+def _thumbprint(jwk_dict: dict[str, Any]) -> str:
+ """Compute JWK thumbprint per RFC 7638 using joserfc."""
+ try:
+ return jwk.thumbprint(jwk_dict)
+ except Exception as ex:
+ logger.debug(
+ "JWK thumbprint computation failed: %s (kty=%s, crv=%s)",
+ ex,
+ jwk_dict.get("kty"),
+ jwk_dict.get("crv"),
+ exc_info=True,
+ )
+ raise InvalidAttestationError(description="invalid_jwk") from ex
+
+
+def _extract_cnf_jwk(claims: dict[str, Any]) -> dict[str, Any] | None:
+ """Extract cnf.jwk from attestation payload."""
+ cnf = claims.get("cnf")
+ if not isinstance(cnf, dict):
+ return None
+ cnf_key = cnf.get("jwk")
+ if isinstance(cnf_key, dict):
+ return cnf_key
+ return None
+
+
+async def _lookup_provider_key(
+ iss: str, kid: str | None = None
+) -> dict[str, Any] | list[dict[str, Any]] | None:
+ """Look up provider key(s) from admin internal API.
+
+ When *kid* is provided, returns a single JWK dict.
+ When *kid* is absent, returns a list of JWK dicts for trial verification.
+ """
+ url = f"{settings.INTERNAL_BASE_URL}/wallet-providers/lookup"
+ headers = internal_api_headers(settings.INTERNAL_AUTH_TOKEN)
+ params: dict[str, str] = {"iss": iss}
+ if kid:
+ params["kid"] = kid
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ resp = await client.get(url, params=params, headers=headers)
+ if resp.status_code != 200:
+ logger.debug(
+ "Provider lookup non-200 iss=%s kid=%s status=%d",
+ iss,
+ kid,
+ resp.status_code,
+ )
+ raise AttestationLookupError(f"lookup status {resp.status_code}")
+ data = resp.json()
+ if not data.get("found"):
+ logger.debug("Provider lookup not found iss=%s kid=%s", iss, kid)
+ return None
+ # Single key when kid was specified
+ if kid:
+ logger.debug("Provider lookup hit iss=%s kid=%s (single key)", iss, kid)
+ return data.get("public_key")
+ # All keys when kid was absent
+ keys = data.get("keys") or []
+ logger.debug(
+ "Provider lookup hit iss=%s (no kid, %d keys for trial)",
+ iss,
+ len(keys) if isinstance(keys, list) else -1,
+ )
+ return keys
+ except AttestationLookupError:
+ raise
+ except Exception as ex:
+ logger.warning(
+ "Failed to look up provider key iss=%s kid=%s",
+ iss,
+ kid,
+ exc_info=True,
+ )
+ raise AttestationLookupError("provider lookup failed") from ex
+
+
+def _verify_signature(
+ token: str,
+ public_key_jwk: dict[str, Any] | list[dict[str, Any]],
+ *,
+ error: str = "attestation_signature_invalid",
+) -> dict[str, Any]:
+ """Verify JWT signature. Accepts a single JWK or list for trial."""
+ # joserfc KeySet lookup requires a `kid`; draft-07 5.1 makes it optional,
+ # so unlabelled keys are tried one by one.
+ if isinstance(public_key_jwk, list):
+ for candidate in public_key_jwk:
+ try:
+ key = jwk.import_key(candidate)
+ result = jwt.decode(token, key, algorithms=list(SUPPORTED_SIGNING_ALGS))
+ return dict(result.claims)
+ except Exception:
+ continue
+ logger.debug(
+ "Signature verification failed (%s): trial keys n=%d, algs=%s",
+ error,
+ len(public_key_jwk),
+ list(SUPPORTED_SIGNING_ALGS),
+ )
+ raise InvalidAttestationError(description=error)
+
+ try:
+ key = jwk.import_key(public_key_jwk)
+ result = jwt.decode(token, key, algorithms=list(SUPPORTED_SIGNING_ALGS))
+ return dict(result.claims)
+ except Exception as ex:
+ logger.debug(
+ "Signature verification failed (%s): %s [kty=%s kid=%s crv=%s, algs=%s]",
+ error,
+ ex,
+ public_key_jwk.get("kty"),
+ public_key_jwk.get("kid"),
+ public_key_jwk.get("crv"),
+ list(SUPPORTED_SIGNING_ALGS),
+ exc_info=True,
+ )
+ raise InvalidAttestationError(description=error) from ex
+
+
+async def validate_client_attestation(
+ *,
+ client_attestation: str | None,
+ client_attestation_pop: str | None = None,
+ attestation_required: bool,
+ expected_audience: str,
+ db=None,
+) -> dict[str, Any] | None:
+ """Validate client attestation JWT + PoP against the trusted provider allow list.
+
+ Implements draft-ietf-oauth-attestation-based-client-auth-07 §6.2 and §9.
+ """
+ if not client_attestation:
+ # draft-07 9 step 1: a PoP without its attestation is a malformed request.
+ if client_attestation_pop:
+ logger.debug("Attestation PoP present without attestation header")
+ raise InvalidAttestationError(description="missing_client_attestation")
+ if attestation_required:
+ logger.debug("Attestation required but missing")
+ raise InvalidAttestationError(description="missing_client_attestation")
+ return None
+
+ logger.debug(
+ "Validating client attestation (attestation_len=%d, pop_present=%s, "
+ "expected_audience=%s, attestation_required=%s)",
+ len(client_attestation),
+ bool(client_attestation_pop),
+ expected_audience,
+ attestation_required,
+ )
+
+ # --- Attestation JWT validation (§5.1, §9 steps 2-6) ---
+
+ # 1. Decode header and payload (without verification first, to extract iss + kid)
+ header, claims = _jwt_extract(client_attestation)
+ logger.debug(
+ "Attestation header: typ=%s alg=%s kid=%s | claims keys=%s",
+ header.get("typ"),
+ header.get("alg"),
+ header.get("kid"),
+ sorted(claims.keys()),
+ )
+
+ # 2. Validate typ header (§5.1: REQUIRED, MUST be "oauth-client-attestation+jwt")
+ typ = header.get("typ")
+ if typ != "oauth-client-attestation+jwt":
+ logger.debug("Attestation typ mismatch: got %r", typ)
+ raise InvalidAttestationError(description="invalid_attestation_typ")
+
+ # 3. Extract kid from header (OPTIONAL per spec), iss from payload
+ kid = header.get("kid")
+ if kid is not None and not isinstance(kid, str):
+ logger.debug("Attestation header kid invalid type: %s", type(kid).__name__)
+ raise InvalidAttestationError(description="invalid_kid")
+ issuer = _required_claim_str(claims, "iss")
+ logger.debug("Attestation iss=%s kid=%s", issuer, kid)
+
+ # 4. Look up provider key(s) by iss + optional kid (§9 step 5)
+ provider_key = await _lookup_provider_key(issuer, kid or None)
+ if not provider_key:
+ logger.debug("Attestation provider untrusted/unknown: iss=%s kid=%s", issuer, kid)
+ raise InvalidAttestationError(description="untrusted_provider")
+
+ # 5. Verify signature using provider's public key (§9 step 5)
+ verified_claims = _verify_signature(client_attestation, provider_key)
+
+ # 6. Validate required claims (§5.1)
+ subject = _required_claim_str(verified_claims, "sub")
+ issued_at = verified_claims.get("iat")
+ expires_at = verified_claims.get("exp")
+ if not isinstance(expires_at, int):
+ logger.debug(
+ "Attestation missing/invalid exp: type=%s", type(expires_at).__name__
+ )
+ raise InvalidAttestationError(description="missing_exp")
+
+ # 7. Check time validity (§9 step 11)
+ skew = int(settings.ATTESTATION_CLOCK_SKEW_SECONDS)
+ now = _now_ts()
+ if issued_at is not None and int(issued_at) > now + skew:
+ logger.debug(
+ "Attestation iat in future: iat=%s now=%d skew=%d iss=%s",
+ issued_at,
+ now,
+ skew,
+ issuer,
+ )
+ raise InvalidAttestationError(description="attestation_not_yet_valid")
+ if int(expires_at) <= now - skew:
+ logger.debug(
+ "Attestation expired: exp=%d now=%d skew=%d iss=%s sub=%s",
+ int(expires_at),
+ now,
+ skew,
+ issuer,
+ subject,
+ )
+ raise InvalidAttestationError(description="attestation_expired")
+ nbf = verified_claims.get("nbf")
+ if isinstance(nbf, int) and nbf > now + skew:
+ logger.debug(
+ "Attestation nbf in future: nbf=%d now=%d skew=%d iss=%s",
+ nbf,
+ now,
+ skew,
+ issuer,
+ )
+ raise InvalidAttestationError(description="attestation_not_yet_valid")
+
+ # 8. Extract cnf.jwk — REQUIRED per §5.1
+ cnf_jwk = _extract_cnf_jwk(verified_claims)
+ if not cnf_jwk:
+ logger.debug(
+ "Attestation missing cnf.jwk: cnf=%r iss=%s sub=%s",
+ verified_claims.get("cnf"),
+ issuer,
+ subject,
+ )
+ raise InvalidAttestationError(description="missing_cnf_jwk")
+
+ # 8b. cnf key MUST NOT be a private key (§9 step 6)
+ if "d" in cnf_jwk:
+ logger.debug(
+ "Attestation cnf.jwk contains private key material: iss=%s sub=%s kty=%s",
+ issuer,
+ subject,
+ cnf_jwk.get("kty"),
+ )
+ raise InvalidAttestationError(description="cnf_contains_private_key")
+
+ cnf_jkt = _thumbprint(cnf_jwk)
+ logger.debug("Attestation cnf jkt=%s (kty=%s)", cnf_jkt, cnf_jwk.get("kty"))
+
+ # --- Attestation PoP JWT validation (§5.2, §9 steps 7-10) ---
+
+ if not client_attestation_pop:
+ logger.debug("Attestation PoP missing: iss=%s sub=%s", issuer, subject)
+ raise InvalidAttestationError(description="missing_client_attestation_pop")
+
+ # 9. Validate PoP typ header
+ # (§5.2: REQUIRED, MUST be "oauth-client-attestation-pop+jwt")
+ pop_header, _ = _jwt_extract(client_attestation_pop)
+ pop_typ = pop_header.get("typ")
+ if pop_typ != "oauth-client-attestation-pop+jwt":
+ logger.debug("Attestation PoP typ mismatch: got %r", pop_typ)
+ raise InvalidAttestationError(description="invalid_attestation_pop_typ")
+
+ # 10. Verify PoP signature using cnf.jwk from attestation (§9 step 7)
+ pop_claims = _verify_signature(
+ client_attestation_pop, cnf_jwk, error="attestation_pop_signature_invalid"
+ )
+ logger.debug(
+ "Attestation PoP claims keys=%s aud=%r iat=%s exp=%s",
+ sorted(pop_claims.keys()),
+ pop_claims.get("aud"),
+ pop_claims.get("iat"),
+ pop_claims.get("exp"),
+ )
+
+ # 11. Verify PoP aud (§5.2: REQUIRED, must be AS issuer identifier)
+ pop_aud = pop_claims.get("aud")
+ if not pop_aud:
+ logger.debug("Attestation PoP missing aud: iss=%s sub=%s", issuer, subject)
+ raise InvalidAttestationError(description="missing_attestation_pop_aud")
+
+ # 11b. Validate PoP aud value matches the expected AS issuer (§5.2, §8 step 10)
+ aud_values = pop_aud if isinstance(pop_aud, list) else [pop_aud]
+ if expected_audience not in aud_values:
+ logger.debug(
+ "Attestation PoP aud mismatch: expected=%s got=%r iss=%s sub=%s",
+ expected_audience,
+ pop_aud,
+ issuer,
+ subject,
+ )
+ raise InvalidAttestationError(description="attestation_pop_aud_mismatch")
+
+ # 12. Verify PoP jti is present (§5.2: REQUIRED)
+ pop_jti = pop_claims.get("jti")
+ if not isinstance(pop_jti, str) or not pop_jti:
+ logger.debug(
+ "Attestation PoP missing/invalid jti: type=%s iss=%s sub=%s",
+ type(pop_jti).__name__,
+ issuer,
+ subject,
+ )
+ raise InvalidAttestationError(description="missing_attestation_pop_jti")
+
+ # 12b. Check jti replay (§12.1: SHOULD detect replay via jti)
+ jti_window = _jti_window()
+ if not await _attest_pop_jti_cache.check_and_store(
+ pop_jti, db, expires_at=now + jti_window
+ ):
+ logger.debug(
+ "Attestation PoP jti replay detected: jti=%s iss=%s sub=%s",
+ pop_jti,
+ issuer,
+ subject,
+ )
+ raise InvalidAttestationError(description="attestation_pop_jti_replay")
+
+ # 13. Verify PoP iat is present and within window (§5.2: REQUIRED)
+ pop_iat = pop_claims.get("iat")
+ if not isinstance(pop_iat, int):
+ logger.debug(
+ "Attestation PoP missing/invalid iat: type=%s iss=%s sub=%s",
+ type(pop_iat).__name__,
+ issuer,
+ subject,
+ )
+ raise InvalidAttestationError(description="missing_attestation_pop_iat")
+ if pop_iat > now + skew:
+ logger.debug(
+ "Attestation PoP iat in future: iat=%d now=%d skew=%d iss=%s",
+ pop_iat,
+ now,
+ skew,
+ issuer,
+ )
+ raise InvalidAttestationError(description="attestation_pop_not_yet_valid")
+ if pop_iat < now - jti_window:
+ logger.debug(
+ "Attestation PoP too old: iat=%d now=%d window=%d iss=%s",
+ pop_iat,
+ now,
+ jti_window,
+ issuer,
+ )
+ raise InvalidAttestationError(description="attestation_pop_too_old")
+
+ # 14. Verify PoP nbf if present (RFC7519 via §5.2 rule 5)
+ pop_nbf = pop_claims.get("nbf")
+ if isinstance(pop_nbf, int) and pop_nbf > now + skew:
+ logger.debug(
+ "Attestation PoP nbf in future: nbf=%d now=%d skew=%d iss=%s",
+ pop_nbf,
+ now,
+ skew,
+ issuer,
+ )
+ raise InvalidAttestationError(description="attestation_pop_not_yet_valid")
+
+ # 15. Verify PoP exp if present (RFC7519 via §5.2 rule 5)
+ pop_exp = pop_claims.get("exp")
+ if isinstance(pop_exp, int) and pop_exp <= now - skew:
+ logger.debug(
+ "Attestation PoP exp passed: exp=%d now=%d skew=%d iss=%s",
+ pop_exp,
+ now,
+ skew,
+ issuer,
+ )
+ raise InvalidAttestationError(description="attestation_pop_expired")
+
+ logger.debug(
+ "Attestation verified: iss=%s sub=%s kid=%s cnf_jkt=%s pop_jti=%s",
+ issuer,
+ subject,
+ kid,
+ cnf_jkt,
+ pop_jti,
+ )
+
+ return {
+ "present": True,
+ "verified": True,
+ "iss": issuer,
+ "kid": kid,
+ "sub": subject,
+ "cnf_jkt": cnf_jkt,
+ "pop_jti": pop_jti,
+ "iat": issued_at,
+ "exp": expires_at,
+ }
diff --git a/oid4vc/auth_server/tenant/services/grant_service.py b/oid4vc/auth_server/tenant/services/grant_service.py
index e4816ea4c..ba35a8a67 100644
--- a/oid4vc/auth_server/tenant/services/grant_service.py
+++ b/oid4vc/auth_server/tenant/services/grant_service.py
@@ -15,7 +15,7 @@
def new_code() -> str:
- """Generate a new code."""
+ """Random pre-auth code."""
return secrets.token_urlsafe(settings.TOKEN_BYTES)
@@ -38,6 +38,7 @@ async def ensure_subject(
return subj.id
except IntegrityError:
# Race: subject with this uid created concurrently
+ await db.rollback()
sid2 = await repo.get_id_by_uid(uid)
if sid2 is None:
raise
@@ -59,7 +60,8 @@ async def create_pre_authorized_code(
else:
authorization_details_dict = None
now = utcnow()
- ttl = ttl_seconds if ttl_seconds and ttl_seconds > 0 else settings.PRE_AUTH_CODE_TTL
+ max_ttl = settings.PRE_AUTH_CODE_TTL
+ ttl = min(ttl_seconds, max_ttl) if ttl_seconds and ttl_seconds > 0 else max_ttl
repo = GrantRepository(db)
pac = await repo.create_pre_auth_code(
subject_id=sid,
diff --git a/oid4vc/auth_server/tenant/services/introspect_service.py b/oid4vc/auth_server/tenant/services/introspect_service.py
index d967135a6..104114a3c 100644
--- a/oid4vc/auth_server/tenant/services/introspect_service.py
+++ b/oid4vc/auth_server/tenant/services/introspect_service.py
@@ -7,6 +7,8 @@
from core.security.utils import utcnow
from tenant.repositories.access_token_repository import AccessTokenRepository
+_INACTIVE: dict[str, Any] = {"active": False}
+
async def introspect_access_token(
db: AsyncSession, tenant_uid: str, token_str: str
@@ -15,28 +17,32 @@ async def introspect_access_token(
repo = AccessTokenRepository(db)
token = await repo.get_by_token(token_str)
- if token is None:
- return {"active": False}
-
- if token.revoked or token.expires_at is None or token.expires_at <= utcnow():
- return {"active": False}
- if not token.subject or not token.subject.uid:
- return {"active": False}
+ # Evaluate all conditions regardless of outcome to normalize timing
+ active = token is not None
+ if active:
+ active = not token.revoked
+ if active:
+ active = token.expires_at is not None and token.expires_at > utcnow()
+ if active:
+ active = bool(token.subject and token.subject.uid)
+ if active:
+ meta = token.token_metadata or {}
+ active = meta.get("realm") == tenant_uid
+ else:
+ meta = {}
- meta = token.token_metadata or {}
- realm = meta.get("realm")
- if realm is not None and realm != tenant_uid:
- return {"active": False}
+ if not active:
+ return _INACTIVE
- token_type = meta.get("token_type") or ("DPoP" if token.cnf_jkt else "Bearer")
+ token_type = meta.get("token_type") or "Bearer"
resp: dict[str, Any] = {
"active": True,
"token_type": token_type,
"sub": token.subject.uid,
"exp": int(token.expires_at.timestamp()),
"iat": int(token.issued_at.timestamp()),
- "realm": realm,
+ "realm": meta.get("realm"),
}
if token.cnf_jkt:
resp["cnf"] = {"jkt": token.cnf_jkt}
diff --git a/oid4vc/auth_server/tenant/services/signing_service.py b/oid4vc/auth_server/tenant/services/signing_service.py
index 2253c6079..40387fa0b 100644
--- a/oid4vc/auth_server/tenant/services/signing_service.py
+++ b/oid4vc/auth_server/tenant/services/signing_service.py
@@ -4,7 +4,7 @@
import httpx
-from core.observability.observability import current_request_id
+from core.observability.observability import internal_api_headers
from core.utils.retry import with_retries
from tenant.config import settings
@@ -31,10 +31,7 @@ async def remote_sign_jwt(
payload: dict[str, Any] = {"claims": claims}
if kid:
payload["kid"] = kid
- headers = {"Authorization": f"Bearer {settings.INTERNAL_AUTH_TOKEN}"}
- rid = current_request_id()
- if rid:
- headers["X-Request-ID"] = rid
+ headers = internal_api_headers(settings.INTERNAL_AUTH_TOKEN)
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.post(url, json=payload, headers=headers)
# 4xx and 5xx will be handled by decorator's should_retry predicate
diff --git a/oid4vc/auth_server/tenant/services/token_service.py b/oid4vc/auth_server/tenant/services/token_service.py
index da4085495..0e11f527e 100644
--- a/oid4vc/auth_server/tenant/services/token_service.py
+++ b/oid4vc/auth_server/tenant/services/token_service.py
@@ -1,24 +1,33 @@
"""Issue/rotate tokens via remote signer, using tenant DB only."""
+import hmac
import secrets
from typing import Any
from fastapi import HTTPException, status
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from core.utils.logging import get_logger
+
from core.security.utils import (
- compute_access_exp,
- compute_refresh_exp,
hash_token,
- new_refresh_token,
utcnow,
)
from tenant.config import settings
+from tenant.models import Subject
from tenant.repositories.access_token_repository import AccessTokenRepository
from tenant.repositories.grant_repository import GrantRepository
from tenant.repositories.refresh_token_repository import RefreshTokenRepository
+from tenant.security.token import (
+ compute_access_exp,
+ compute_refresh_exp,
+ new_refresh_token,
+)
from tenant.services.signing_service import remote_sign_jwt
+logger = get_logger(__name__)
+
def _coerce_authorization_details(value: Any) -> list[dict[str, Any]]:
"""Return authorization_details as a list of dicts, filtering invalid entries."""
@@ -29,6 +38,23 @@ def _coerce_authorization_details(value: Any) -> list[dict[str, Any]]:
return []
+def _coerce_amr(value: Any) -> list[str]:
+ """Return amr as a list of non-empty strings."""
+ if isinstance(value, str):
+ return [value] if value else []
+ if isinstance(value, list):
+ return [item for item in value if isinstance(item, str) and item]
+ return []
+
+
+def _merge_amr(existing: Any, value: str) -> list[str]:
+ """Return unique AMR values preserving order."""
+ amr_values = _coerce_amr(existing)
+ if value not in amr_values:
+ amr_values.append(value)
+ return amr_values
+
+
class TokenService:
"""Issue/rotate tokens via remote signer, using tenant DB only."""
@@ -39,6 +65,7 @@ async def issue_by_pre_auth_code(
code: str,
realm: str,
tx_code: str | None = None,
+ attestation: dict[str, Any] | None = None,
):
"""Issue access+refresh from a pre-auth code."""
grant_repo = GrantRepository(db)
@@ -49,15 +76,24 @@ async def issue_by_pre_auth_code(
now = utcnow()
pac = await grant_repo.get_by_code(code)
- if pac is None:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_grant"
+ if pac is None or pac.used or pac.expires_at <= now:
+ reason = (
+ "not_found" if pac is None else "already_used" if pac.used else "expired"
)
- if pac.tx_code and tx_code != pac.tx_code:
+ logger.warning("pre_auth_code rejected: %s", reason)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_grant"
)
- # Atomically consume the PAC to prevent race/double-spend
+ # tx_code brute-force check
+ if pac.tx_code and not hmac.compare_digest(tx_code or "", pac.tx_code):
+ attempts = await grant_repo.increment_tx_code_attempts(
+ pac.id, settings.MAX_TX_CODE_ATTEMPTS
+ )
+ await db.commit()
+ remaining = max(0, settings.MAX_TX_CODE_ATTEMPTS - attempts)
+ detail = "invalid_grant" if remaining == 0 else "invalid_tx_code"
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
+ # Consume the PAC atomically (prevents double-spend)
consumed = await grant_repo.consume_valid(pac.id, now)
if not consumed:
raise HTTPException(
@@ -85,20 +121,22 @@ async def issue_by_pre_auth_code(
c_nonce_expires_in = settings.ACCESS_TOKEN_TTL
response_meta["c_nonce"] = c_nonce
response_meta["c_nonce_expires_in"] = c_nonce_expires_in
+ if isinstance(attestation, dict):
+ response_meta["attestation"] = attestation
+ response_meta["amr"] = _merge_amr(response_meta.get("amr"), "att-pop")
- sign_res = await remote_sign_jwt(
- uid=uid,
- claims=claims,
- )
+ sign_res = await remote_sign_jwt(uid=uid, claims=claims)
token_meta: dict[str, Any] = {"iss": issuer, "realm": realm}
token_meta.update(response_meta)
+ cnf_jkt = attestation.get("cnf_jkt") if isinstance(attestation, dict) else None
access_token = await access_repo.create(
subject_id=pac.subject_id,
token=sign_res["jwt"],
issued_at=now,
expires_at=access_exp,
token_metadata=token_meta,
+ cnf_jkt=cnf_jkt,
)
refresh_token = new_refresh_token()
@@ -119,6 +157,7 @@ async def rotate_by_refresh_token(
uid: str,
refresh_token_value: str,
realm: str,
+ attestation: dict[str, Any] | None = None,
):
"""Rotate tokens using a refresh token."""
access_repo = AccessTokenRepository(db)
@@ -131,6 +170,26 @@ async def rotate_by_refresh_token(
token_hash = hash_token(refresh_token_value)
res = await refresh_repo.consume_valid(token_hash=token_hash, now=now)
if not res:
+ # Check if this is a reuse of an already-consumed token (breach signal)
+ reuse_subject = await refresh_repo.is_token_reuse(token_hash)
+ if reuse_subject is not None:
+ logger.warning(
+ "refresh token reuse detected, revoking family subject_id=%s",
+ reuse_subject,
+ )
+ # SELECT FOR UPDATE to serialize concurrent issuance
+ # before revoking the subject's token family.
+ await db.execute(
+ select(Subject.id)
+ .where(Subject.id == reuse_subject)
+ .with_for_update()
+ )
+ # Revoke the token family (OAuth Security BCP §4.14.2)
+ await access_repo.revoke_all_for_subject(reuse_subject)
+ await refresh_repo.revoke_all_for_subject(reuse_subject)
+ await db.commit()
+ else:
+ logger.warning("refresh token not found or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_token"
)
@@ -142,6 +201,24 @@ async def rotate_by_refresh_token(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="subject_uid_missing",
)
+ # draft-07 10.3: the refresh token is bound to the attested client instance
+ # key, so rotation MUST present the same key.
+ bound_cnf_jkt = getattr(prev_access, "cnf_jkt", None) or None
+ if bound_cnf_jkt:
+ presented_cnf_jkt = (
+ attestation.get("cnf_jkt") if isinstance(attestation, dict) else None
+ )
+ if presented_cnf_jkt != bound_cnf_jkt:
+ logger.warning(
+ "refresh attestation key mismatch subject_id=%s", subject_id
+ )
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="invalid_token",
+ )
+
+ # Revoke the previous access token immediately on rotation
+ prev_access.revoked = True
prev_meta = prev_access.token_metadata or {}
prev_authz = (
_coerce_authorization_details(prev_meta.get("authorization_details"))
@@ -163,19 +240,40 @@ async def rotate_by_refresh_token(
response_meta["c_nonce"] = c_nonce
response_meta["c_nonce_expires_in"] = c_nonce_expires_in
- sign_res = await remote_sign_jwt(
- uid=uid,
- claims=claims,
+ effective_attestation = None
+ if isinstance(attestation, dict):
+ effective_attestation = attestation
+ elif isinstance(prev_meta, dict) and isinstance(
+ prev_meta.get("attestation"), dict
+ ):
+ effective_attestation = prev_meta.get("attestation")
+ if isinstance(effective_attestation, dict):
+ response_meta["attestation"] = effective_attestation
+
+ amr_values = (
+ _coerce_amr(prev_meta.get("amr")) if isinstance(prev_meta, dict) else []
)
+ if isinstance(effective_attestation, dict):
+ amr_values = _merge_amr(amr_values, "att-pop")
+ if amr_values:
+ response_meta["amr"] = amr_values
- token_meta = {"iss": issuer, "realm": realm}
+ sign_res = await remote_sign_jwt(uid=uid, claims=claims)
+
+ token_meta: dict[str, Any] = {"iss": issuer, "realm": realm}
token_meta.update(response_meta)
+ cnf_jkt = (
+ effective_attestation.get("cnf_jkt")
+ if isinstance(effective_attestation, dict)
+ else None
+ )
new_access_token = await access_repo.create(
subject_id=subject_id,
token=sign_res["jwt"],
issued_at=now,
expires_at=access_exp,
token_metadata=token_meta,
+ cnf_jkt=cnf_jkt,
)
refresh_token = new_refresh_token()
diff --git a/oid4vc/auth_server/tenant/services/well_known_service.py b/oid4vc/auth_server/tenant/services/well_known_service.py
index 5e87a855f..577eb6c1d 100644
--- a/oid4vc/auth_server/tenant/services/well_known_service.py
+++ b/oid4vc/auth_server/tenant/services/well_known_service.py
@@ -4,18 +4,20 @@
from fastapi import Request
-from core.consts import OAuth2GrantType
+from core.consts import OAuth2GrantType, SUPPORTED_SIGNING_ALGS
+from core.utils.logging import get_logger
from tenant.config import settings
-from tenant.deps import get_tenant_jwks
+from tenant.deps import get_tenant_ctx, get_tenant_jwks
-try:
- _TRUSTED_NETWORKS = [
- ipaddress.ip_network(cidr.strip())
- for cidr in getattr(settings, "TRUSTED_NETWORKS", [])
- if cidr and cidr.strip()
- ]
-except ValueError:
- _TRUSTED_NETWORKS = []
+logger = get_logger(__name__)
+
+_TRUSTED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
+for _cidr in getattr(settings, "TRUSTED_NETWORKS", []):
+ if _cidr and isinstance(_cidr, str) and _cidr.strip():
+ try:
+ _TRUSTED_NETWORKS.append(ipaddress.ip_network(_cidr.strip()))
+ except ValueError:
+ logger.warning("Invalid CIDR in TRUSTED_NETWORKS, skipping: %s", _cidr)
def is_internal_request(request: Request) -> bool:
@@ -30,38 +32,51 @@ def is_internal_request(request: Request) -> bool:
return any(ip_obj in net for net in _TRUSTED_NETWORKS)
-def build_oauth_auth_server(uid: str, request: Request) -> dict:
+async def build_oauth_auth_server(uid: str, request: Request) -> dict:
"""Build OIDC discovery for a tenant."""
+ # Verify tenant exists (triggers fetch/cache from Admin API)
+ await get_tenant_ctx(uid, "db")
+
base_url = settings.ISSUER_BASE_URL + f"/tenants/{uid}"
well_known_base_url = settings.ISSUER_BASE_URL + "/.well-known"
doc = {
"issuer": base_url,
"token_endpoint": f"{base_url}/token",
- "token_endpoint_auth_methods_supported": [
- "client_secret_basic",
- "client_secret_jwt",
- "private_key_jwt",
- ],
+ "response_types_supported": [],
+ "token_endpoint_auth_methods_supported": ["none"],
+ "token_endpoint_auth_signing_alg_values_supported": list(SUPPORTED_SIGNING_ALGS),
"grant_types_supported": [
OAuth2GrantType.PRE_AUTH_CODE,
OAuth2GrantType.REFRESH_TOKEN,
],
"authorization_details_types_supported": ["openid_credential"],
+ "pre-authorized_grant_anonymous_access_supported": True,
"jwks_uri": f"{well_known_base_url}/jwks.json/tenants/{uid}",
}
+ if settings.ATTESTATION_ENABLED:
+ if settings.ATTESTATION_REQUIRED:
+ doc["token_endpoint_auth_methods_supported"] = ["attest_jwt_client_auth"]
+ doc["pre-authorized_grant_anonymous_access_supported"] = False
+ else:
+ doc["token_endpoint_auth_methods_supported"].append("attest_jwt_client_auth")
+ doc["client_attestation_signing_alg_values_supported"] = list(
+ SUPPORTED_SIGNING_ALGS
+ )
+ doc["client_attestation_pop_signing_alg_values_supported"] = list(
+ SUPPORTED_SIGNING_ALGS
+ )
+
if is_internal_request(request):
doc["introspection_endpoint"] = f"{base_url}/introspect"
doc["introspection_endpoint_auth_methods_supported"] = [
"client_secret_basic",
- "client_secret_jwt",
"private_key_jwt",
]
- doc["introspection_endpoint_auth_signing_alg_values_supported"] = [
- "ES256",
- "HS256",
- ]
+ doc["introspection_endpoint_auth_signing_alg_values_supported"] = list(
+ SUPPORTED_SIGNING_ALGS
+ )
return doc
diff --git a/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py b/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py
index 6afed88c0..59efe7c44 100644
--- a/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py
+++ b/oid4vc/auth_server/tests/admin/services/test_admin_signing_service.py
@@ -49,12 +49,14 @@ async def test_sign_tenant_jwt_success(monkeypatch):
signing_service, "datetime", SimpleNamespace(now=lambda tz=None: now)
)
monkeypatch.setattr(signing_service, "decrypt_private_pem", lambda pem: "private-key")
- monkeypatch.setattr(signing_service.JsonWebKey, "import_key", lambda pem: "jwk")
+ monkeypatch.setattr(signing_service.jwk, "import_key", lambda pem, kty=None: "jwk")
class FakeJwt:
@staticmethod
def encode(header, claims, jwk):
- return "token".encode()
+ return "token"
+
+ JWTClaimsRegistry = None
monkeypatch.setattr(signing_service, "jwt", FakeJwt)
diff --git a/oid4vc/auth_server/tests/admin/services/test_internal_service.py b/oid4vc/auth_server/tests/admin/services/test_internal_service.py
index 4a3449c4f..75fe8dfb1 100644
--- a/oid4vc/auth_server/tests/admin/services/test_internal_service.py
+++ b/oid4vc/auth_server/tests/admin/services/test_internal_service.py
@@ -4,6 +4,7 @@
import pytest
from fastapi import HTTPException
+from joserfc import jwk as jose_jwk
from sqlalchemy.ext.asyncio import AsyncSession
from admin.services import internal_service
@@ -122,7 +123,7 @@ def now(cls, tz=None):
def _import_key(jwk):
return SimpleNamespace(as_dict=lambda **kwargs: {**jwk, **kwargs})
- monkeypatch.setattr(internal_service.JsonWebKey, "import_key", _import_key)
+ monkeypatch.setattr(internal_service.jwk, "import_key", _import_key)
session = cast(AsyncSession, DummySession(DummyListResult(rows)))
@@ -139,3 +140,42 @@ async def test_get_tenant_jwks_empty_when_no_rows(monkeypatch):
result = await internal_service.get_tenant_jwks(session, "tenant-1")
assert result == {"keys": []}
+
+
+@pytest.mark.asyncio
+async def test_lookup_revalidates_stale_provider_from_db(monkeypatch):
+ """A stale cache entry is refreshed from the DB before use."""
+ key = jose_jwk.ECKey.generate_key("P-256")
+ jwks = {"keys": [key.as_dict(private=False, kid="k1")]}
+ row = SimpleNamespace(iss="https://wp.example", jwks=jwks, jwks_uri=None, active=True)
+
+ internal_service._provider_jwks_cache.invalidate(row.iss)
+ session = cast(AsyncSession, DummySession(DummyScalarResult(row)))
+
+ result = await internal_service.lookup_wallet_provider(session, row.iss)
+
+ assert result is not None
+ assert len(result["keys"]) == 1
+
+
+@pytest.mark.asyncio
+async def test_lookup_drops_deactivated_provider_on_revalidation(monkeypatch):
+ """A provider deactivated in another worker stops being trusted once stale."""
+ key = jose_jwk.ECKey.generate_key("P-256")
+ jwks = {"keys": [key.as_dict(private=False, kid="k1")]}
+ iss = "https://revoked.example"
+
+ # Seed the cache as if this worker had loaded it at startup.
+ internal_service._provider_jwks_cache.put(iss, jwks, jwks_uri=None)
+ assert internal_service._provider_jwks_cache.get_keyset(iss) is not None
+
+ # Force staleness; the DB now reports the provider as inactive.
+ monkeypatch.setattr(
+ internal_service._provider_jwks_cache, "is_stale", lambda _key: True
+ )
+ inactive = SimpleNamespace(iss=iss, jwks=jwks, jwks_uri=None, active=False)
+ session = cast(AsyncSession, DummySession(DummyScalarResult(inactive)))
+
+ result = await internal_service.lookup_wallet_provider(session, iss)
+
+ assert result is None
diff --git a/oid4vc/auth_server/tests/core/security/test_jwks_cache.py b/oid4vc/auth_server/tests/core/security/test_jwks_cache.py
new file mode 100644
index 000000000..0027bfdd3
--- /dev/null
+++ b/oid4vc/auth_server/tests/core/security/test_jwks_cache.py
@@ -0,0 +1,190 @@
+from unittest.mock import AsyncMock, patch
+
+import httpx
+from joserfc.jwk import ECKey, KeySet
+
+from core.security.jwks_cache import JWKSCache
+
+# Generate real EC keys so KeySet.import_key_set works
+_raw_k1 = ECKey.generate_key("P-256").as_dict(private=False, kid="k1")
+_raw_k2 = ECKey.generate_key("P-256").as_dict(private=False, kid="k2")
+
+JWKS_A = {"keys": [_raw_k1]}
+JWKS_B = {"keys": [_raw_k1, _raw_k2]}
+KS_A = KeySet.import_key_set(JWKS_A)
+KS_B = KeySet.import_key_set(JWKS_B)
+
+
+# ── URI mode (get_jwks) ──────────────────────────────────
+
+
+class TestJWKSCacheGetJwks:
+ async def test_returns_fetched_jwks(self):
+ cache = JWKSCache(ttl=300)
+ with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_A)):
+ result = await cache.get_jwks("https://example.com/jwks")
+ assert len(result.keys) == 1
+ assert result.keys[0].kid == "k1"
+
+ async def test_returns_cached_within_ttl(self):
+ cache = JWKSCache(ttl=300)
+ fetch = AsyncMock(return_value=KS_A)
+ with patch.object(cache, "_fetch", new=fetch):
+ await cache.get_jwks("https://example.com/jwks")
+ result = await cache.get_jwks("https://example.com/jwks")
+ assert len(result.keys) == 1
+ assert fetch.call_count == 1
+
+ async def test_refreshes_after_ttl_expires(self):
+ cache = JWKSCache(ttl=10)
+ fetch = AsyncMock(return_value=KS_A)
+ with patch.object(cache, "_fetch", new=fetch):
+ await cache.get_jwks("https://example.com/jwks")
+ ts, ks, uri = cache._cache["https://example.com/jwks"]
+ cache._cache["https://example.com/jwks"] = (ts - 20, ks, uri)
+ await cache.get_jwks("https://example.com/jwks")
+ assert fetch.call_count == 2
+
+ async def test_refreshes_on_kid_miss(self):
+ cache = JWKSCache(ttl=300)
+ fetch = AsyncMock(side_effect=[KS_A, KS_B])
+ with patch.object(cache, "_fetch", new=fetch):
+ await cache.get_jwks("https://example.com/jwks")
+ result = await cache.get_jwks(
+ "https://example.com/jwks",
+ kid="k2",
+ )
+ assert len(result.keys) == 2
+ assert fetch.call_count == 2
+
+ async def test_no_refresh_when_kid_found(self):
+ cache = JWKSCache(ttl=300)
+ fetch = AsyncMock(return_value=KS_A)
+ with patch.object(cache, "_fetch", new=fetch):
+ await cache.get_jwks("https://example.com/jwks")
+ result = await cache.get_jwks(
+ "https://example.com/jwks",
+ kid="k1",
+ )
+ assert len(result.keys) == 1
+ assert fetch.call_count == 1
+
+ async def test_returns_stale_cache_on_fetch_failure(self):
+ cache = JWKSCache(ttl=10)
+ with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_A)):
+ await cache.get_jwks("https://example.com/jwks")
+ ts, ks, uri = cache._cache["https://example.com/jwks"]
+ cache._cache["https://example.com/jwks"] = (ts - 20, ks, uri)
+ with patch.object(
+ cache,
+ "_fetch",
+ new=AsyncMock(side_effect=httpx.HTTPError("timeout")),
+ ):
+ result = await cache.get_jwks("https://example.com/jwks")
+ assert len(result.keys) == 1
+
+ async def test_returns_none_on_fetch_failure_no_stale(self):
+ cache = JWKSCache(ttl=300)
+ with patch.object(
+ cache,
+ "_fetch",
+ new=AsyncMock(side_effect=httpx.HTTPError("fail")),
+ ):
+ assert await cache.get_jwks("https://example.com/jwks") is None
+
+ async def test_separate_uris_cached_independently(self):
+ cache = JWKSCache(ttl=300)
+ ks_other = KeySet.import_key_set({"keys": [_raw_k2]})
+ fetch = AsyncMock(side_effect=[KS_A, ks_other])
+ with patch.object(cache, "_fetch", new=fetch):
+ r1 = await cache.get_jwks("https://a.example.com/jwks")
+ r2 = await cache.get_jwks("https://b.example.com/jwks")
+ assert r1.keys[0].kid == "k1"
+ assert r2.keys[0].kid == "k2"
+ assert fetch.call_count == 2
+
+
+class TestJWKSCacheInvalidate:
+ async def test_invalidate_forces_refetch(self):
+ cache = JWKSCache(ttl=300)
+ fetch = AsyncMock(return_value=KS_A)
+ with patch.object(cache, "_fetch", new=fetch):
+ await cache.get_jwks("https://example.com/jwks")
+ cache.invalidate("https://example.com/jwks")
+ await cache.get_jwks("https://example.com/jwks")
+ assert fetch.call_count == 2
+
+ async def test_invalidate_nonexistent_is_noop(self):
+ cache = JWKSCache(ttl=300)
+ cache.invalidate("https://nonexistent.example.com/jwks")
+
+
+# ── Put mode (put + get_key) ─────────────────────────────
+
+
+class TestJWKSCachePut:
+ def test_put_stores_entry(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A)
+ assert "iss1" in cache._cache
+
+ def test_put_with_jwks_uri(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks")
+ _, _, uri = cache._cache["iss1"]
+ assert uri == "https://p.example/jwks"
+
+ def test_put_overwrites_existing(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A)
+ cache.put("iss1", JWKS_B)
+ _, ks, _ = cache._cache["iss1"]
+ assert len(ks.keys) == 2
+
+
+class TestJWKSCacheGetKey:
+ async def test_returns_key_from_put_entry(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A)
+ result = await cache.get_key("iss1", "k1")
+ assert result is not None
+ assert result.kid == "k1"
+
+ async def test_returns_none_for_unknown_iss(self):
+ cache = JWKSCache(ttl=300)
+ assert await cache.get_key("unknown", "k1") is None
+
+ async def test_inline_kid_miss_returns_none(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A)
+ assert await cache.get_key("iss1", "k999") is None
+
+ async def test_jwks_uri_refreshes_on_kid_miss(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks")
+ with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_B)):
+ result = await cache.get_key("iss1", "k2")
+ assert result is not None
+ assert result.kid == "k2"
+
+ async def test_jwks_uri_refresh_still_missing(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks")
+ with patch.object(cache, "_fetch", new=AsyncMock(return_value=KS_A)):
+ assert await cache.get_key("iss1", "k999") is None
+
+ async def test_jwks_uri_refresh_failure(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A, jwks_uri="https://p.example/jwks")
+ with patch.object(
+ cache,
+ "_fetch",
+ new=AsyncMock(side_effect=httpx.HTTPError("fail")),
+ ):
+ assert await cache.get_key("iss1", "k999") is None
+
+ async def test_invalidate_then_get_key(self):
+ cache = JWKSCache(ttl=300)
+ cache.put("iss1", JWKS_A)
+ cache.invalidate("iss1")
+ assert await cache.get_key("iss1", "k1") is None
diff --git a/oid4vc/auth_server/tests/tenant/oauth/test_grants.py b/oid4vc/auth_server/tests/tenant/oauth/test_grants.py
index 536957b4f..7a1f45314 100644
--- a/oid4vc/auth_server/tests/tenant/oauth/test_grants.py
+++ b/oid4vc/auth_server/tests/tenant/oauth/test_grants.py
@@ -1,10 +1,13 @@
from types import SimpleNamespace
from typing import Any, cast
+from unittest.mock import AsyncMock
import pytest
from authlib.oauth2.rfc6749 import AuthorizationServer, OAuth2Request
+from fastapi import HTTPException
from tenant.oauth.grants import PreAuthorizedCodeGrant, RotatingRefreshTokenGrant
+from tenant.services.attestation_service import AttestationLookupError
class DummyServer(AuthorizationServer):
@@ -16,8 +19,13 @@ async def save_token(self, token, request): # type: ignore[override]
self.saved = (token, request)
-def make_request(data: dict, *, url: str = "https://example.org/token") -> OAuth2Request:
- req = OAuth2Request(method="POST", uri=url)
+def make_request(
+ data: dict,
+ *,
+ url: str = "https://example.org/token",
+ headers: dict[str, str] | None = None,
+) -> OAuth2Request:
+ req = OAuth2Request(method="POST", uri=url, headers=headers)
cast(Any, req).payload = SimpleNamespace(data=data, grant_type=data.get("grant_type"))
return req
@@ -90,3 +98,84 @@ async def test_refresh_grant_create_token_response(monkeypatch):
assert extra_ctx.token_ctx.get("refresh_token") == "rt"
assert extra_ctx.token_ctx.get("realm") == "tenant-2"
assert server.saved is not None and server.saved[0] == {}
+
+
+@pytest.mark.asyncio
+async def test_disabled_security_features_skip_validation(monkeypatch):
+ server = DummyServer()
+ request = make_request({"pre-authorized_code": "abc"})
+ attestation_validator = AsyncMock()
+ monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_ENABLED", False)
+ monkeypatch.setattr(
+ "tenant.oauth.grants.validate_client_attestation", attestation_validator
+ )
+
+ grant = PreAuthorizedCodeGrant(request, server)
+ await grant.validate_token_request()
+
+ attestation_validator.assert_not_awaited()
+ assert grant._attestation_meta is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("enabled", "required", "expected_required"),
+ [
+ (False, False, None),
+ (True, False, False),
+ (True, True, True),
+ ],
+)
+async def test_attestation_policy_matrix(
+ monkeypatch, enabled, required, expected_required
+):
+ server = DummyServer()
+ request = make_request(
+ {"pre_authorized_code": "abc"},
+ headers={
+ "OAuth-Client-Attestation": "attestation",
+ "OAuth-Client-Attestation-PoP": "proof",
+ },
+ )
+ extra_ctx = SimpleNamespace(uid="tenant-1", db=object())
+ validator = AsyncMock(return_value={"cnf_jkt": "attestation-jkt"})
+ monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_ENABLED", enabled)
+ monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_REQUIRED", required)
+ monkeypatch.setattr("tenant.oauth.grants.get_context", lambda _req: extra_ctx)
+ monkeypatch.setattr("tenant.oauth.grants.validate_client_attestation", validator)
+
+ grant = PreAuthorizedCodeGrant(request, server)
+ await grant.validate_token_request()
+
+ if expected_required is None:
+ validator.assert_not_awaited()
+ assert grant._attestation_meta is None
+ else:
+ assert validator.await_args.kwargs["attestation_required"] is expected_required
+ assert grant._attestation_meta == {"cnf_jkt": "attestation-jkt"}
+
+
+@pytest.mark.asyncio
+async def test_provider_lookup_outage_is_not_a_client_error(monkeypatch):
+ """A provider lookup outage returns 503, not a 4xx client error."""
+ server = DummyServer()
+ request = make_request(
+ {"pre_authorized_code": "abc"},
+ headers={
+ "OAuth-Client-Attestation": "attestation",
+ "OAuth-Client-Attestation-PoP": "proof",
+ },
+ )
+ extra_ctx = SimpleNamespace(uid="tenant-1", db=object())
+ monkeypatch.setattr("tenant.oauth.grants.get_context", lambda _req: extra_ctx)
+ monkeypatch.setattr("tenant.oauth.grants.settings.ATTESTATION_ENABLED", True)
+ monkeypatch.setattr(
+ "tenant.oauth.grants.validate_client_attestation",
+ AsyncMock(side_effect=AttestationLookupError("admin unreachable")),
+ )
+
+ grant = PreAuthorizedCodeGrant(request, server)
+ with pytest.raises(HTTPException) as exc_info:
+ await grant.validate_token_request()
+
+ assert exc_info.value.status_code == 503
diff --git a/oid4vc/auth_server/tests/tenant/services/test_attestation_service.py b/oid4vc/auth_server/tests/tenant/services/test_attestation_service.py
new file mode 100644
index 000000000..fa155beb3
--- /dev/null
+++ b/oid4vc/auth_server/tests/tenant/services/test_attestation_service.py
@@ -0,0 +1,762 @@
+"""Tests for attestation service (kid + allow list design)."""
+
+import base64
+import json
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from joserfc import jwk as jose_jwk
+from joserfc import jwt as jose_jwt
+
+from tenant.services import attestation_service
+
+
+def _b64(data: dict) -> str:
+ encoded = json.dumps(data, separators=(",", ":")).encode("utf-8")
+ return base64.urlsafe_b64encode(encoded).decode("ascii").rstrip("=")
+
+
+# --- Helpers to generate real signed JWTs ---
+
+
+def _generate_provider_key():
+ """Generate a wallet provider EC key pair."""
+ key = jose_jwk.ECKey.generate_key("P-256")
+ return key, key.as_dict(private=False)
+
+
+def _generate_wallet_key():
+ """Generate a wallet instance EC key pair."""
+ key = jose_jwk.ECKey.generate_key("P-256")
+ return key, key.as_dict(private=False)
+
+
+def _sign_attestation(provider_key, kid, payload):
+ """Sign an attestation JWT with the provider's key."""
+ header = {"alg": "ES256", "typ": "oauth-client-attestation+jwt", "kid": kid}
+ return jose_jwt.encode(header, payload, provider_key)
+
+
+def _sign_pop(wallet_key, payload):
+ """Sign an attestation PoP JWT with the wallet instance's key."""
+ header = {"alg": "ES256", "typ": "oauth-client-attestation-pop+jwt"}
+ return jose_jwt.encode(header, payload, wallet_key)
+
+
+def _make_attestation_and_pop(
+ provider_key,
+ kid,
+ wallet_key,
+ wallet_public,
+ *,
+ iss="https://wallet-provider.example",
+ sub="Ontario Wallet",
+ att_iat=1_699_999_900,
+ att_exp=1_700_000_300,
+ pop_iss=None,
+ pop_aud="https://as.example.com",
+ pop_jti="unique-jti-1",
+ pop_iat=1_700_000_000,
+):
+ """Build both attestation + PoP JWTs with sensible defaults."""
+ attestation = _sign_attestation(
+ provider_key,
+ kid,
+ {
+ "iss": iss,
+ "sub": sub,
+ "cnf": {"jwk": wallet_public},
+ "iat": att_iat,
+ "exp": att_exp,
+ },
+ )
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": pop_iss if pop_iss is not None else sub,
+ "aud": pop_aud,
+ "jti": pop_jti,
+ "iat": pop_iat,
+ },
+ )
+ return attestation, pop
+
+
+@pytest.fixture
+def default_settings(monkeypatch):
+ monkeypatch.setattr(
+ attestation_service.settings,
+ "ATTESTATION_CLOCK_SKEW_SECONDS",
+ 60,
+ raising=False,
+ )
+ monkeypatch.setattr(
+ attestation_service.settings,
+ "INTERNAL_BASE_URL",
+ "http://admin:9000/internal",
+ raising=False,
+ )
+ monkeypatch.setattr(
+ attestation_service.settings,
+ "INTERNAL_AUTH_TOKEN",
+ "test-token",
+ raising=False,
+ )
+ # Swap DB-backed JTI cache for async-compatible in-memory version in tests
+
+ _seen: set[str] = set()
+
+ async def _async_check_and_store(jti, db=None, now=None, expires_at=None):
+ if not jti:
+ return False
+ if jti in _seen:
+ return False
+ _seen.add(jti)
+ return True
+
+ test_cache = MagicMock()
+ test_cache.check_and_store = _async_check_and_store
+ monkeypatch.setattr(attestation_service, "_attest_pop_jti_cache", test_cache)
+
+
+@pytest.fixture
+def provider_keys():
+ """Fixture: generate provider key pair."""
+ key, public = _generate_provider_key()
+ return key, public
+
+
+async def test_optional_missing_returns_none(default_settings):
+ result = await attestation_service.validate_client_attestation(
+ client_attestation=None,
+ attestation_required=False,
+ expected_audience="https://as.example.com",
+ )
+ assert result is None
+
+
+async def test_required_missing_raises(default_settings):
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=None,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert exc_info.value.error == "invalid_client_attestation"
+
+
+async def test_invalid_kid_type_raises(default_settings):
+ """Attestation JWT with non-string kid in header should fail."""
+ # Build a JWT with kid as integer (invalid type)
+ header = _b64({"alg": "ES256", "typ": "oauth-client-attestation+jwt", "kid": 123})
+ payload = _b64(
+ {"iss": "https://provider.example", "sub": "wallet", "iat": 1, "exp": 2}
+ )
+ token = f"{header}.{payload}.fakesig"
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "invalid_kid" in exc_info.value.description
+
+
+async def test_untrusted_provider_raises(default_settings, provider_keys, monkeypatch):
+ """Attestation from unknown provider (not in allow list) should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, _ = provider_keys
+
+ token = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://unknown-provider.example",
+ "sub": "wallet-app",
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+
+ # Mock the lookup to return not found
+ mock_lookup = AsyncMock(return_value=None)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "untrusted_provider" in exc_info.value.description
+ mock_lookup.assert_awaited_once_with("https://unknown-provider.example", "key-1")
+
+
+async def test_valid_attestation_with_provider_lookup(
+ default_settings, provider_keys, monkeypatch
+):
+ """Valid attestation + PoP with kid lookup from allow list succeeds."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation, pop = _make_attestation_and_pop(
+ provider_key,
+ "key-1",
+ wallet_key,
+ wallet_public,
+ )
+
+ # Mock the lookup to return the provider's public key
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ result = await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+
+ assert result is not None
+ assert result["verified"] is True
+ assert result["iss"] == "https://wallet-provider.example"
+ assert result["kid"] == "key-1"
+ assert result["sub"] == "Ontario Wallet"
+ assert result["pop_jti"] == "unique-jti-1"
+ assert result["cnf_jkt"] is not None
+ mock_lookup.assert_awaited_once_with("https://wallet-provider.example", "key-1")
+
+
+async def test_invalid_signature_raises(default_settings, provider_keys, monkeypatch):
+ """Attestation signed with wrong key should fail signature verification."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ _, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ # Sign with a DIFFERENT key
+ wrong_key, _ = _generate_provider_key()
+ token = _sign_attestation(
+ wrong_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": "Ontario Wallet",
+ "aud": "https://as.example.com",
+ "jti": "jti-1",
+ "iat": 1_700_000_000,
+ },
+ )
+
+ # Return the REAL provider's public key — signature won't match
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "signature_invalid" in exc_info.value.description
+
+
+async def test_expired_attestation_raises(default_settings, provider_keys, monkeypatch):
+ """Expired attestation should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_001_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ token = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300, # expired by now (1_700_001_000)
+ },
+ )
+
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": "Ontario Wallet",
+ "aud": "https://as.example.com",
+ "jti": "jti-1",
+ "iat": 1_700_001_000,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "expired" in exc_info.value.description
+
+
+# --- New tests for typ, cnf, and PoP validation ---
+
+
+async def test_invalid_attestation_typ_raises(default_settings, monkeypatch):
+ """Attestation JWT with wrong typ header should fail."""
+ provider_key, _ = _generate_provider_key()
+ # Sign with wrong typ
+ header = {"alg": "ES256", "typ": "jwt", "kid": "key-1"}
+ token = jose_jwt.encode(
+ header,
+ {
+ "iss": "https://provider.example",
+ "sub": "wallet",
+ "iat": 1,
+ "exp": 2,
+ },
+ provider_key,
+ )
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "invalid_attestation_typ" in exc_info.value.description
+
+
+async def test_missing_cnf_jwk_raises(default_settings, provider_keys, monkeypatch):
+ """Attestation without cnf.jwk should fail (now required)."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+
+ # Attestation without cnf claim
+ token = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ client_attestation_pop=None,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "missing_cnf_jwk" in exc_info.value.description
+
+
+async def test_missing_pop_raises(default_settings, provider_keys, monkeypatch):
+ """Attestation present but PoP missing should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ _, wallet_public = _generate_wallet_key()
+
+ token = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ client_attestation_pop=None,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "missing_client_attestation_pop" in exc_info.value.description
+
+
+async def test_pop_wrong_signature_raises(default_settings, provider_keys, monkeypatch):
+ """PoP signed with wrong key should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ _, wallet_public = _generate_wallet_key()
+
+ token = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+
+ # Sign PoP with a DIFFERENT key (not the wallet key)
+ wrong_key, _ = _generate_wallet_key()
+ pop = _sign_pop(
+ wrong_key,
+ {
+ "iss": "Ontario Wallet",
+ "aud": "https://as.example.com",
+ "jti": "jti-1",
+ "iat": 1_700_000_000,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "pop_signature_invalid" in exc_info.value.description
+
+
+async def test_pop_missing_aud_raises(default_settings, provider_keys, monkeypatch):
+ """PoP without aud claim should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": "Ontario Wallet",
+ "jti": "jti-1",
+ "iat": 1_700_000_000,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "missing_attestation_pop_aud" in exc_info.value.description
+
+
+async def test_pop_aud_mismatch_raises(default_settings, provider_keys, monkeypatch):
+ """PoP aud that doesn't match expected_audience should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation, pop = _make_attestation_and_pop(
+ provider_key,
+ "key-1",
+ wallet_key,
+ wallet_public,
+ pop_aud="https://other-as.example.com",
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "attestation_pop_aud_mismatch" in exc_info.value.description
+
+
+async def test_pop_aud_array_accepted(default_settings, provider_keys, monkeypatch):
+ """PoP aud as array containing expected_audience should succeed."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": "Ontario Wallet",
+ "aud": ["https://as.example.com", "https://other.example.com"],
+ "jti": "jti-1",
+ "iat": 1_700_000_000,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ result = await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert result is not None
+ assert result["verified"] is True
+
+
+async def test_pop_missing_jti_raises(default_settings, provider_keys, monkeypatch):
+ """PoP without jti claim should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": "Ontario Wallet",
+ "aud": "https://as.example.com",
+ "iat": 1_700_000_000,
+ },
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "missing_attestation_pop_jti" in exc_info.value.description
+
+
+async def test_pop_invalid_typ_raises(default_settings, provider_keys, monkeypatch):
+ """PoP with wrong typ header should fail."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+ # Sign PoP with wrong typ
+ pop_header = {"alg": "ES256", "typ": "jwt"}
+ pop = jose_jwt.encode(
+ pop_header,
+ {
+ "iss": "Ontario Wallet",
+ "aud": "https://as.example.com",
+ "jti": "jti-1",
+ "iat": 1_700_000_000,
+ },
+ wallet_key,
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "invalid_attestation_pop_typ" in exc_info.value.description
+
+
+async def test_pop_jti_replay_raises(default_settings, provider_keys, monkeypatch):
+ """Replayed PoP jti should be rejected."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ attestation, pop = _make_attestation_and_pop(
+ provider_key,
+ "key-1",
+ wallet_key,
+ wallet_public,
+ pop_jti="replay-jti-1",
+ )
+
+ mock_lookup = AsyncMock(return_value=provider_public)
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ # First call succeeds
+ result = await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert result is not None
+ assert result["verified"] is True
+
+ # Second call with same jti is rejected
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+ assert "attestation_pop_jti_replay" in exc_info.value.description
+
+
+async def test_attestation_without_kid_verifies_by_trial(
+ default_settings, provider_keys, monkeypatch
+):
+ """draft-07 5.1 makes `kid` optional; unlabelled keys are tried in turn."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, provider_public = provider_keys
+ wallet_key, wallet_public = _generate_wallet_key()
+
+ # Attestation header carries no kid.
+ attestation = jose_jwt.encode(
+ {"alg": "ES256", "typ": "oauth-client-attestation+jwt"},
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "Ontario Wallet",
+ "cnf": {"jwk": wallet_public},
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ provider_key,
+ )
+ pop = _sign_pop(
+ wallet_key,
+ {
+ "iss": "Ontario Wallet",
+ "aud": "https://as.example.com",
+ "jti": "nokid-jti-1",
+ "iat": 1_700_000_000,
+ },
+ )
+
+ # No kid -> admin returns the full key set for trial verification.
+ decoy, _ = _generate_provider_key()
+ mock_lookup = AsyncMock(return_value=[decoy.as_dict(private=False), provider_public])
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ result = await attestation_service.validate_client_attestation(
+ client_attestation=attestation,
+ client_attestation_pop=pop,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
+
+ assert result is not None and result["verified"] is True
+ assert result["kid"] is None
+ mock_lookup.assert_awaited_once_with("https://wallet-provider.example", None)
+
+
+async def test_pop_without_attestation_raises(default_settings):
+ """draft-07 9 step 1: both headers are required together."""
+ with pytest.raises(attestation_service.InvalidAttestationError) as exc_info:
+ await attestation_service.validate_client_attestation(
+ client_attestation=None,
+ client_attestation_pop="some-pop-jwt",
+ attestation_required=False,
+ expected_audience="https://as.example.com",
+ )
+ assert "missing_client_attestation" in exc_info.value.description
+
+
+async def test_provider_lookup_failure_is_not_a_client_error(
+ default_settings, provider_keys, monkeypatch
+):
+ """A lookup outage must not be reported as an untrusted provider."""
+ monkeypatch.setattr(attestation_service, "_now_ts", lambda: 1_700_000_000)
+ provider_key, _ = provider_keys
+
+ token = _sign_attestation(
+ provider_key,
+ "key-1",
+ {
+ "iss": "https://wallet-provider.example",
+ "sub": "wallet-app",
+ "iat": 1_699_999_900,
+ "exp": 1_700_000_300,
+ },
+ )
+
+ mock_lookup = AsyncMock(
+ side_effect=attestation_service.AttestationLookupError("admin down")
+ )
+ monkeypatch.setattr(attestation_service, "_lookup_provider_key", mock_lookup)
+
+ with pytest.raises(attestation_service.AttestationLookupError):
+ await attestation_service.validate_client_attestation(
+ client_attestation=token,
+ attestation_required=True,
+ expected_audience="https://as.example.com",
+ )
diff --git a/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py b/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py
index 899e28137..733b5a654 100644
--- a/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py
+++ b/oid4vc/auth_server/tests/tenant/services/test_introspect_service.py
@@ -34,7 +34,7 @@ async def test_introspect_returns_active_payload(monkeypatch):
"realm": "tenant-1",
"iss": "https://issuer",
"authorization_details": [{"type": "openid_credential"}],
- "amr": ["dpop"],
+ "amr": ["att-pop"],
"attestation": {"type": "device"},
"scope": "openid",
"c_nonce": "nonce",
@@ -54,7 +54,7 @@ async def test_introspect_returns_active_payload(monkeypatch):
assert resp["active"] is True
assert resp["sub"] == "subject-1"
- assert resp["token_type"] == "DPoP"
+ assert resp["token_type"] == "Bearer"
assert resp["realm"] == "tenant-1"
assert resp["iss"] == "https://issuer"
assert resp["authorization_details"] == [{"type": "openid_credential"}]
diff --git a/oid4vc/auth_server/tests/tenant/services/test_signing_service.py b/oid4vc/auth_server/tests/tenant/services/test_signing_service.py
index 7c8eb4359..beab56af2 100644
--- a/oid4vc/auth_server/tests/tenant/services/test_signing_service.py
+++ b/oid4vc/auth_server/tests/tenant/services/test_signing_service.py
@@ -42,7 +42,11 @@ async def post(self, url, json, headers):
)
monkeypatch.setattr(signing_service.settings, "INTERNAL_BASE_URL", "https://admin")
monkeypatch.setattr(signing_service.settings, "INTERNAL_AUTH_TOKEN", "token")
- monkeypatch.setattr(signing_service, "current_request_id", lambda: "req-123")
+ monkeypatch.setattr(
+ signing_service,
+ "internal_api_headers",
+ lambda token: {"Authorization": f"Bearer {token}", "X-Request-ID": "req-123"},
+ )
result = await signing_service.remote_sign_jwt(
uid="tenant-1", claims={"sub": "abc"}, kid="kid1"
@@ -81,7 +85,11 @@ async def post(self, url, *args, **kwargs):
)
monkeypatch.setattr(signing_service.settings, "INTERNAL_BASE_URL", "https://admin")
monkeypatch.setattr(signing_service.settings, "INTERNAL_AUTH_TOKEN", "token")
- monkeypatch.setattr(signing_service, "current_request_id", lambda: None)
+ monkeypatch.setattr(
+ signing_service,
+ "internal_api_headers",
+ lambda token: {"Authorization": f"Bearer {token}"},
+ )
result = await signing_service.remote_sign_jwt(uid="tenant-1", claims={"sub": "abc"})
@@ -109,7 +117,11 @@ async def post(self, url, *args, **kwargs):
)
monkeypatch.setattr(signing_service.settings, "INTERNAL_BASE_URL", "https://admin")
monkeypatch.setattr(signing_service.settings, "INTERNAL_AUTH_TOKEN", "token")
- monkeypatch.setattr(signing_service, "current_request_id", lambda: None)
+ monkeypatch.setattr(
+ signing_service,
+ "internal_api_headers",
+ lambda token: {"Authorization": f"Bearer {token}"},
+ )
with pytest.raises(signing_service.httpx.HTTPStatusError):
await signing_service.remote_sign_jwt(uid="tenant-1", claims={})
diff --git a/oid4vc/auth_server/tests/tenant/services/test_token_service.py b/oid4vc/auth_server/tests/tenant/services/test_token_service.py
index 5f11d3a91..20877379b 100644
--- a/oid4vc/auth_server/tests/tenant/services/test_token_service.py
+++ b/oid4vc/auth_server/tests/tenant/services/test_token_service.py
@@ -8,6 +8,8 @@
from tenant.services import token_service
+_FAR_FUTURE = datetime(2099, 1, 1, tzinfo=timezone.utc)
+
class DummySession(AsyncSession):
def __init__(self):
@@ -26,6 +28,8 @@ async def test_issue_by_pre_auth_code_excludes_realm_from_claims(monkeypatch):
pac = SimpleNamespace(
id=1,
tx_code=None,
+ used=False,
+ expires_at=_FAR_FUTURE,
subject=SimpleNamespace(uid="sub-123"),
subject_id=10,
authorization_details=[{"type": "openid_credential", "format": "mso_mdoc"}],
@@ -275,6 +279,8 @@ async def test_issue_by_pre_auth_code_includes_nonce_when_enabled(monkeypatch):
pac = SimpleNamespace(
id=5,
tx_code=None,
+ used=False,
+ expires_at=_FAR_FUTURE,
subject=SimpleNamespace(uid="sub-789"),
subject_id=77,
authorization_details=None,
@@ -458,3 +464,114 @@ async def create(self, **kwargs):
refresh_meta = refresh_repo_instance.created_with.get("token_metadata")
assert refresh_meta is not None
assert refresh_meta["realm"] == "tenant-rot"
+
+
+def _rotation_stubs(monkeypatch, prev_access, *, now):
+ """Wire the repositories and helpers used by rotate_by_refresh_token."""
+ access_exp = now + timedelta(minutes=10)
+ refresh_exp = now + timedelta(days=3)
+
+ class StubAccessRepo:
+ def __init__(self, _db):
+ self.created_with = None
+
+ async def create(self, **kwargs):
+ self.created_with = kwargs
+ return SimpleNamespace(id=88, token=kwargs["token"])
+
+ async def get_by_id(self, _access_token_id):
+ return prev_access
+
+ class StubRefreshRepo:
+ def __init__(self, _db):
+ self.created_with = None
+
+ async def consume_valid(self, token_hash, now):
+ return (55, prev_access.id)
+
+ async def create(self, **kwargs):
+ self.created_with = kwargs
+ return SimpleNamespace()
+
+ monkeypatch.setattr(token_service, "AccessTokenRepository", StubAccessRepo)
+ monkeypatch.setattr(token_service, "RefreshTokenRepository", StubRefreshRepo)
+ monkeypatch.setattr(token_service, "hash_token", lambda value: f"hash-{value}")
+ monkeypatch.setattr(token_service, "utcnow", lambda: now)
+ monkeypatch.setattr(token_service, "compute_access_exp", lambda _now: access_exp)
+ monkeypatch.setattr(token_service, "compute_refresh_exp", lambda _now: refresh_exp)
+ monkeypatch.setattr(token_service, "new_refresh_token", lambda: "refresh-new")
+ monkeypatch.setattr(token_service.settings, "INCLUDE_NONCE", False, raising=False)
+ monkeypatch.setattr(
+ token_service, "remote_sign_jwt", AsyncMock(return_value={"jwt": "new-signed"})
+ )
+
+
+async def test_rotate_requires_matching_attestation_key(monkeypatch):
+ """draft-07 10.3: refreshing MUST present the bound client instance key."""
+ now = datetime(2025, 2, 2, tzinfo=timezone.utc)
+ prev_access = SimpleNamespace(
+ id=77,
+ subject=SimpleNamespace(uid="sub-456"),
+ token_metadata={"realm": "tenant-2"},
+ cnf_jkt="bound-jkt",
+ )
+ _rotation_stubs(monkeypatch, prev_access, now=now)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await token_service.TokenService.rotate_by_refresh_token(
+ db=DummySession(),
+ uid="tenant-2",
+ refresh_token_value="existing-refresh",
+ realm="tenant-2",
+ attestation={"cnf_jkt": "different-jkt"},
+ )
+ assert exc_info.value.status_code == 401
+
+
+async def test_rotate_rejects_missing_attestation_when_bound(monkeypatch):
+ """A bound refresh token cannot be redeemed without any attestation."""
+ now = datetime(2025, 2, 2, tzinfo=timezone.utc)
+ prev_access = SimpleNamespace(
+ id=77,
+ subject=SimpleNamespace(uid="sub-456"),
+ token_metadata={"realm": "tenant-2"},
+ cnf_jkt="bound-jkt",
+ )
+ _rotation_stubs(monkeypatch, prev_access, now=now)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await token_service.TokenService.rotate_by_refresh_token(
+ db=DummySession(),
+ uid="tenant-2",
+ refresh_token_value="existing-refresh",
+ realm="tenant-2",
+ attestation=None,
+ )
+ assert exc_info.value.status_code == 401
+
+
+async def test_rotate_accepts_matching_attestation_key(monkeypatch):
+ """Presenting the bound key rotates successfully and stays bound."""
+ now = datetime(2025, 2, 2, tzinfo=timezone.utc)
+ prev_access = SimpleNamespace(
+ id=77,
+ subject=SimpleNamespace(uid="sub-456"),
+ token_metadata={"realm": "tenant-2"},
+ cnf_jkt="bound-jkt",
+ )
+ _rotation_stubs(monkeypatch, prev_access, now=now)
+
+ (
+ access_token,
+ refresh_token,
+ _meta,
+ ) = await token_service.TokenService.rotate_by_refresh_token(
+ db=DummySession(),
+ uid="tenant-2",
+ refresh_token_value="existing-refresh",
+ realm="tenant-2",
+ attestation={"cnf_jkt": "bound-jkt"},
+ )
+
+ assert access_token.token == "new-signed"
+ assert refresh_token == "refresh-new"
diff --git a/oid4vc/auth_server/tests/tenant/services/test_well_known_service.py b/oid4vc/auth_server/tests/tenant/services/test_well_known_service.py
new file mode 100644
index 000000000..1dc19980e
--- /dev/null
+++ b/oid4vc/auth_server/tests/tenant/services/test_well_known_service.py
@@ -0,0 +1,67 @@
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from core.consts import SUPPORTED_SIGNING_ALGS
+from tenant.services.well_known_service import build_oauth_auth_server
+
+
+@pytest.mark.asyncio
+async def test_security_features_are_not_advertised_when_disabled(monkeypatch):
+ monkeypatch.setattr("tenant.services.well_known_service.get_tenant_ctx", AsyncMock())
+ monkeypatch.setattr(
+ "tenant.services.well_known_service.settings.ATTESTATION_ENABLED", False
+ )
+
+ doc = await build_oauth_auth_server(
+ "tenant-1", SimpleNamespace(client=SimpleNamespace(host="203.0.113.1"))
+ )
+
+ assert doc["token_endpoint_auth_methods_supported"] == ["none"]
+ assert "client_attestation_signing_alg_values_supported" not in doc
+ assert "client_attestation_pop_signing_alg_values_supported" not in doc
+
+
+@pytest.mark.asyncio
+async def test_security_features_are_advertised_when_enabled(monkeypatch):
+ monkeypatch.setattr("tenant.services.well_known_service.get_tenant_ctx", AsyncMock())
+ monkeypatch.setattr(
+ "tenant.services.well_known_service.settings.ATTESTATION_ENABLED", True
+ )
+ monkeypatch.setattr(
+ "tenant.services.well_known_service.settings.ATTESTATION_REQUIRED", False
+ )
+
+ doc = await build_oauth_auth_server(
+ "tenant-1", SimpleNamespace(client=SimpleNamespace(host="203.0.113.1"))
+ )
+
+ assert doc["token_endpoint_auth_methods_supported"] == [
+ "none",
+ "attest_jwt_client_auth",
+ ]
+ assert doc["client_attestation_signing_alg_values_supported"] == list(
+ SUPPORTED_SIGNING_ALGS
+ )
+ assert doc["client_attestation_pop_signing_alg_values_supported"] == list(
+ SUPPORTED_SIGNING_ALGS
+ )
+
+
+@pytest.mark.asyncio
+async def test_required_attestation_does_not_advertise_none(monkeypatch):
+ monkeypatch.setattr("tenant.services.well_known_service.get_tenant_ctx", AsyncMock())
+ monkeypatch.setattr(
+ "tenant.services.well_known_service.settings.ATTESTATION_ENABLED", True
+ )
+ monkeypatch.setattr(
+ "tenant.services.well_known_service.settings.ATTESTATION_REQUIRED", True
+ )
+
+ doc = await build_oauth_auth_server(
+ "tenant-1", SimpleNamespace(client=SimpleNamespace(host="203.0.113.1"))
+ )
+
+ assert doc["token_endpoint_auth_methods_supported"] == ["attest_jwt_client_auth"]
+ assert doc["pre-authorized_grant_anonymous_access_supported"] is False
diff --git a/oid4vc/auth_server/tests/core/security/test_client_auth.py b/oid4vc/auth_server/tests/tenant/test_client_auth.py
similarity index 68%
rename from oid4vc/auth_server/tests/core/security/test_client_auth.py
rename to oid4vc/auth_server/tests/tenant/test_client_auth.py
index bf3742669..48c0c8284 100644
--- a/oid4vc/auth_server/tests/core/security/test_client_auth.py
+++ b/oid4vc/auth_server/tests/tenant/test_client_auth.py
@@ -6,10 +6,11 @@
import pytest
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBasicCredentials
+from joserfc.jwk import ECKey, KeySet
from starlette.requests import Request
from core.consts import ClientAuthMethod
-from core.security import client_auth
+from tenant.security import client_auth
def make_request() -> Request:
@@ -48,22 +49,29 @@ def fake_client(**attrs) -> client_auth.AuthClient:
return cast(client_auth.AuthClient, SimpleNamespace(**attrs))
+# Generate a real EC key for tests that need valid JWKS
+_test_key = ECKey.generate_key("P-256", auto_kid=True)
+_test_jwks = {"keys": [_test_key.as_dict(private=False)]}
+
+
@pytest.mark.asyncio
async def test_load_jwks_from_dict():
- client = fake_client(jwks={"keys": [1]}, jwks_uri=None)
+ client = fake_client(jwks=_test_jwks, jwks_uri=None)
result = await client_auth._load_jwks(client)
- assert result == {"keys": [1]}
+ assert isinstance(result, KeySet)
+ assert len(result.keys) == 1
@pytest.mark.asyncio
async def test_load_jwks_from_json_string():
- client = fake_client(jwks=json.dumps({"keys": [2]}), jwks_uri=None)
+ client = fake_client(jwks=json.dumps(_test_jwks), jwks_uri=None)
result = await client_auth._load_jwks(client)
- assert result == {"keys": [2]}
+ assert isinstance(result, KeySet)
+ assert len(result.keys) == 1
@pytest.mark.asyncio
@@ -77,54 +85,28 @@ async def test_load_jwks_invalid_string_returns_none():
@pytest.mark.asyncio
async def test_load_jwks_from_uri(monkeypatch):
- class FakeResponse:
- def __init__(self, payload):
- self._payload = payload
-
- def raise_for_status(self):
- return None
-
- def json(self):
- return self._payload
-
- class FakeAsyncClient:
- def __init__(self):
- self.called_with = None
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get(self, url):
- self.called_with = url
- return FakeResponse({"keys": [3]})
-
- fake_instance = FakeAsyncClient()
- monkeypatch.setattr(client_auth.httpx, "AsyncClient", lambda **_: fake_instance)
+ fake_keyset = KeySet.import_key_set(_test_jwks)
+ monkeypatch.setattr(
+ client_auth,
+ "_jwks_uri_cache",
+ MagicMock(get_jwks=AsyncMock(return_value=fake_keyset)),
+ )
client = fake_client(jwks=None, jwks_uri="https://example.org/jwks.json")
result = await client_auth._load_jwks(client)
- assert result == {"keys": [3]}
- assert fake_instance.called_with == "https://example.org/jwks.json"
+ assert isinstance(result, KeySet)
+ assert len(result.keys) == 1
@pytest.mark.asyncio
async def test_load_jwks_uri_failure_returns_none(monkeypatch):
- class FakeAsyncClient:
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, tb):
- return False
-
- async def get(self, url):
- raise RuntimeError("boom")
-
- monkeypatch.setattr(client_auth.httpx, "AsyncClient", lambda **_: FakeAsyncClient())
+ monkeypatch.setattr(
+ client_auth,
+ "_jwks_uri_cache",
+ MagicMock(get_jwks=AsyncMock(return_value=None)),
+ )
client = fake_client(jwks=None, jwks_uri="https://bad.example.org")
result = await client_auth._load_jwks(client)
@@ -156,6 +138,7 @@ def test_validate_jwt_claims_success():
"aud": str(request.url),
"exp": 123,
"iat": 100,
+ "jti": "unique-id-1",
}
client_auth._validate_jwt_claims(claims, request)
@@ -179,6 +162,7 @@ def test_validate_jwt_claims_invalid_audience():
"aud": ["https://other"],
"exp": 123,
"iat": 100,
+ "jti": "unique-id-2",
}
with pytest.raises(HTTPException) as exc_info:
@@ -188,149 +172,124 @@ def test_validate_jwt_claims_invalid_audience():
assert exc_info.value.detail == "invalid_audience"
-def test_decode_and_validate_jwt_success(monkeypatch):
+@pytest.mark.asyncio
+async def test_decode_and_validate_jwt_success(monkeypatch):
request = make_request()
- class DummyClaims(dict):
- def validate(self, now=None, leeway=None):
- return None
-
- payload = DummyClaims(
- {
- "iss": "issuer",
- "sub": "subject",
- "aud": str(request.url),
- "exp": 123,
- "iat": 100,
- }
- )
+ payload = {
+ "iss": "issuer",
+ "sub": "subject",
+ "aud": str(request.url),
+ "exp": 123,
+ "iat": 100,
+ "jti": "unique-id-3",
+ }
- jwt_decode_mock = MagicMock(return_value=payload)
+ class FakeToken:
+ claims = payload
+
+ jwt_decode_mock = MagicMock(return_value=FakeToken())
monkeypatch.setattr(client_auth.jwt, "decode", jwt_decode_mock)
monkeypatch.setattr(client_auth, "_validate_jwt_alg", lambda *_: None)
- claims = client_auth._decode_and_validate_jwt(
+ # Also mock JWTClaimsRegistry to avoid real validation
+ fake_registry = MagicMock()
+ fake_registry.validate = MagicMock()
+ monkeypatch.setattr(
+ client_auth.jwt, "JWTClaimsRegistry", MagicMock(return_value=fake_registry)
+ )
+ monkeypatch.setattr(
+ client_auth,
+ "_pkjwt_jti_cache",
+ MagicMock(check_and_store=AsyncMock(return_value=True)),
+ )
+
+ claims = await client_auth._decode_and_validate_jwt(
token="token",
key_material="secret",
request=request,
+ db=AsyncMock(),
expected_alg="RS256",
)
assert claims == payload
- jwt_decode_mock.assert_called_once_with("token", "secret")
+ jwt_decode_mock.assert_called_once_with("token", "secret", algorithms=["RS256"])
-def test_decode_and_validate_jwt_decode_failure(monkeypatch):
+@pytest.mark.asyncio
+async def test_decode_and_validate_jwt_decode_failure(monkeypatch):
request = make_request()
- def raise_error(*_):
+ def raise_error(*_args, **_kwargs):
raise ValueError("bad token")
monkeypatch.setattr(client_auth.jwt, "decode", raise_error)
+ monkeypatch.setattr(client_auth, "jwt_header_unverified", lambda _: {"alg": "ES256"})
with pytest.raises(HTTPException) as exc_info:
- client_auth._decode_and_validate_jwt("token", "secret", request)
+ await client_auth._decode_and_validate_jwt(
+ "token", "secret", request, db=AsyncMock()
+ )
assert exc_info.value.status_code == 401
- assert exc_info.value.detail == "invalid_client_assertion"
+ assert exc_info.value.detail == "invalid_client"
-def test_decode_and_validate_jwt_non_mapping(monkeypatch):
+@pytest.mark.asyncio
+async def test_decode_and_validate_jwt_non_mapping(monkeypatch):
request = make_request()
class Dummy:
- def validate(self, now=None, leeway=None):
- return None
+ claims = "not-a-mapping"
- monkeypatch.setattr(client_auth.jwt, "decode", lambda *_: Dummy())
+ fake_registry = MagicMock()
+ fake_registry.validate = MagicMock()
+ monkeypatch.setattr(
+ client_auth.jwt, "JWTClaimsRegistry", MagicMock(return_value=fake_registry)
+ )
+ monkeypatch.setattr(client_auth.jwt, "decode", lambda *_args, **_kwargs: Dummy())
monkeypatch.setattr(client_auth, "_validate_jwt_claims", lambda *_: None)
+ monkeypatch.setattr(client_auth, "jwt_header_unverified", lambda _: {"alg": "ES256"})
with pytest.raises(HTTPException) as exc_info:
- client_auth._decode_and_validate_jwt("token", "secret", request)
+ await client_auth._decode_and_validate_jwt(
+ "token", "secret", request, db=AsyncMock()
+ )
- assert exc_info.value.detail == "invalid_client_assertion"
+ assert exc_info.value.detail == "invalid_client"
@pytest.mark.asyncio
async def test_authenticate_private_key_jwt_success(monkeypatch):
+ fake_keyset = KeySet.import_key_set(_test_jwks)
client = fake_client(
client_auth_signing_alg="RS256",
client_id="client-1",
- jwks={"keys": ["key"]},
+ jwks=_test_jwks,
jwks_uri=None,
client_secret=None,
)
- monkeypatch.setattr(
- client_auth, "_load_jwks", AsyncMock(return_value={"keys": ["key"]})
- )
- import_mock = MagicMock(return_value="imported-keys")
- monkeypatch.setattr(client_auth.JsonWebKey, "import_key_set", import_mock)
- decode_mock = MagicMock(return_value={"sub": "client-1"})
+ monkeypatch.setattr(client_auth, "_load_jwks", AsyncMock(return_value=fake_keyset))
+ decode_mock = AsyncMock(return_value={"sub": "client-1", "iss": "client-1"})
monkeypatch.setattr(client_auth, "_decode_and_validate_jwt", decode_mock)
result = await client_auth._authenticate_private_key_jwt(
- client, "token", make_request()
+ client, "token", make_request(), db=AsyncMock()
)
- assert result == {"sub": "client-1"}
- import_mock.assert_called_once_with({"keys": ["key"]})
+ assert result == {"sub": "client-1", "iss": "client-1"}
decode_mock.assert_called_once()
@pytest.mark.asyncio
async def test_authenticate_private_key_jwt_missing_keys(monkeypatch):
- client = fake_client(jwks=None, jwks_uri=None, client_secret=None)
+ client = fake_client(jwks=None, jwks_uri=None, client_secret=None, client_id="c1")
monkeypatch.setattr(client_auth, "_load_jwks", AsyncMock(return_value=None))
with pytest.raises(HTTPException) as exc_info:
- await client_auth._authenticate_private_key_jwt(client, "token", make_request())
-
- assert exc_info.value.detail == "invalid_client_keys"
-
-
-@pytest.mark.asyncio
-async def test_authenticate_shared_key_jwt_success(monkeypatch):
- client = fake_client(
- client_id="client-1",
- client_secret="secret",
- client_auth_signing_alg="HS256",
- )
- decode_mock = MagicMock(return_value={"sub": "client-1"})
- monkeypatch.setattr(client_auth, "_decode_and_validate_jwt", decode_mock)
-
- result = await client_auth._authenticate_shared_key_jwt(
- client, "token", make_request(), "client-1"
- )
-
- assert result == {"sub": "client-1"}
- decode_mock.assert_called_once()
-
-
-@pytest.mark.asyncio
-async def test_authenticate_shared_key_jwt_missing_secret():
- client = fake_client(client_secret=None, client_auth_signing_alg="HS256")
-
- with pytest.raises(HTTPException) as exc_info:
- await client_auth._authenticate_shared_key_jwt(
- client, "token", make_request(), "client-1"
- )
-
- assert exc_info.value.detail == "unauthorized_client"
-
-
-@pytest.mark.asyncio
-async def test_authenticate_shared_key_jwt_sub_mismatch(monkeypatch):
- client = fake_client(client_secret="secret", client_auth_signing_alg="HS256")
- monkeypatch.setattr(
- client_auth,
- "_decode_and_validate_jwt",
- MagicMock(return_value={"sub": "other"}),
- )
-
- with pytest.raises(HTTPException) as exc_info:
- await client_auth._authenticate_shared_key_jwt(
- client, "token", make_request(), "client-1"
+ await client_auth._authenticate_private_key_jwt(
+ client, "token", make_request(), db=AsyncMock()
)
assert exc_info.value.detail == "invalid_client"
@@ -375,7 +334,7 @@ async def test_base_client_auth_private_key_jwt_success(monkeypatch, stub_client
private_key_mock = AsyncMock(return_value={})
monkeypatch.setattr(client_auth, "_authenticate_private_key_jwt", private_key_mock)
- result = await client_auth.base_client_auth(
+ result = await client_auth._base_client_auth(
db=AsyncMock(),
request=request,
credentials=credentials,
@@ -384,7 +343,9 @@ async def test_base_client_auth_private_key_jwt_success(monkeypatch, stub_client
assert result is client
assert request.state.client_id == "client-1"
private_key_mock.assert_awaited_once()
- called_client, called_token, called_request = private_key_mock.await_args.args
+ called_client, called_token, called_request, called_db = (
+ private_key_mock.await_args.args
+ )
assert called_client is client
assert called_token == "token-123"
assert called_request is request
@@ -410,7 +371,7 @@ async def test_base_client_auth_client_secret_basic_success(
client_auth, "verify_secret_pbkdf2", lambda token, stored: token == "clear-secret"
)
- result = await client_auth.base_client_auth(
+ result = await client_auth._base_client_auth(
db=AsyncMock(),
request=request,
basic_creds=basic_creds,
@@ -439,7 +400,7 @@ async def test_base_client_auth_client_secret_basic_invalid_secret(
monkeypatch.setattr(client_auth, "verify_secret_pbkdf2", lambda token, stored: False)
with pytest.raises(HTTPException) as exc_info:
- await client_auth.base_client_auth(
+ await client_auth._base_client_auth(
db=AsyncMock(),
request=request,
basic_creds=basic_creds,
@@ -454,7 +415,7 @@ async def test_base_client_auth_missing_credentials():
request = make_request()
with pytest.raises(HTTPException) as exc_info:
- await client_auth.base_client_auth(db=AsyncMock(), request=request)
+ await client_auth._base_client_auth(db=AsyncMock(), request=request)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "unauthorized"
@@ -476,14 +437,14 @@ async def test_base_client_auth_scheme_not_allowed(monkeypatch, stub_client_repo
stub_client_repo(lambda cid: client if cid == "client-1" else None)
with pytest.raises(HTTPException) as exc_info:
- await client_auth.base_client_auth(
+ await client_auth._base_client_auth(
db=AsyncMock(),
request=request,
basic_creds=basic_creds,
)
assert exc_info.value.status_code == 401
- assert exc_info.value.detail == "unauthorized_client"
+ assert exc_info.value.detail == "invalid_client"
@pytest.mark.asyncio
@@ -497,7 +458,7 @@ async def test_base_client_auth_unknown_client(monkeypatch, stub_client_repo):
)
with pytest.raises(HTTPException) as exc_info:
- await client_auth.base_client_auth(
+ await client_auth._base_client_auth(
db=AsyncMock(),
request=request,
credentials=credentials,
diff --git a/oid4vc/auth_server/tests/tenant/test_config.py b/oid4vc/auth_server/tests/tenant/test_config.py
new file mode 100644
index 000000000..51745b996
--- /dev/null
+++ b/oid4vc/auth_server/tests/tenant/test_config.py
@@ -0,0 +1,28 @@
+import pytest
+from pydantic import ValidationError
+
+from tenant.config import Settings
+
+
+@pytest.mark.parametrize(
+ ("enabled", "required"),
+ [(False, False), (True, False), (True, True)],
+)
+def test_attestation_config_accepts_valid_states(enabled, required):
+ config = Settings(
+ _env_file=None,
+ ATTESTATION_ENABLED=enabled,
+ ATTESTATION_REQUIRED=required,
+ )
+
+ assert config.ATTESTATION_ENABLED is enabled
+ assert config.ATTESTATION_REQUIRED is required
+
+
+def test_attestation_config_rejects_required_when_disabled():
+ with pytest.raises(ValidationError, match="ATTESTATION_REQUIRED"):
+ Settings(
+ _env_file=None,
+ ATTESTATION_ENABLED=False,
+ ATTESTATION_REQUIRED=True,
+ )
diff --git a/oid4vc/auth_server/tests/tenant/test_tenant_deps.py b/oid4vc/auth_server/tests/tenant/test_tenant_deps.py
index c50df3098..1d893aa3e 100644
--- a/oid4vc/auth_server/tests/tenant/test_tenant_deps.py
+++ b/oid4vc/auth_server/tests/tenant/test_tenant_deps.py
@@ -148,7 +148,7 @@ def __init__(self):
self.path_params = {"uid": "tenant-1"}
request = DummyRequest()
- ctx = await deps._load_tenant_ctx(cast(deps.Request, request), force=False)
+ ctx = await deps._load_tenant_ctx(cast(deps.Request, request))
async def consume():
async for session in deps.get_db_session(cast(deps.Request, request), ctx):
diff --git a/oid4vc/oid4vc/models/issuer_config.py b/oid4vc/oid4vc/models/issuer_config.py
index 6ee32e8b4..2c76f3d85 100644
--- a/oid4vc/oid4vc/models/issuer_config.py
+++ b/oid4vc/oid4vc/models/issuer_config.py
@@ -73,25 +73,34 @@ def record_value(self) -> dict:
return {prop: getattr(self, prop) for prop in self.ISSUER_ATTRS}
def issuer_metadata(self, base_url: str) -> dict:
- """Return a representation of this record as issuer metadata."""
+ """Return this record as OID4VCI §12.2.4 Credential Issuer metadata.
+
+ Emits every non-null attribute in :data:`ISSUER_ATTRS`. Normalizes
+ `authorization_servers` to public URL strings (§12.2.4 array-of-strings
+ form). Fills the two REQUIRED fields (`credential_issuer`,
+ `credential_endpoint`) from `base_url` when absent; optional fields
+ stay absent unless configured.
+ """
metadata: dict[str, Any] = {
prop: getattr(self, prop)
for prop in self.ISSUER_ATTRS
if getattr(self, prop) is not None
}
if metadata.get("authorization_servers"):
- metadata["authorization_servers"] = [
- server.get("public_url", None)
+ # §12.2.4: array of strings, non-empty. Drop entries without public_url.
+ public_urls = [
+ server["public_url"]
for server in metadata["authorization_servers"]
+ if server.get("public_url")
]
+ if public_urls:
+ metadata["authorization_servers"] = public_urls
+ else:
+ metadata.pop("authorization_servers")
if not metadata.get("credential_issuer"):
metadata["credential_issuer"] = base_url
if not metadata.get("credential_endpoint"):
metadata["credential_endpoint"] = f"{base_url}/credential"
- if not metadata.get("nonce_endpoint"):
- metadata["nonce_endpoint"] = f"{base_url}/nonce"
- if not metadata.get("notification_endpoint"):
- metadata["notification_endpoint"] = f"{base_url}/notification"
return metadata
diff --git a/oid4vc/oid4vc/public_routes/metadata.py b/oid4vc/oid4vc/public_routes/metadata.py
index 584cb0dd2..7c3cef77d 100644
--- a/oid4vc/oid4vc/public_routes/metadata.py
+++ b/oid4vc/oid4vc/public_routes/metadata.py
@@ -7,6 +7,7 @@
from acapy_agent.admin.request_context import AdminRequestContext
from acapy_agent.messaging.models.openapi import OpenAPISchema
+from acapy_agent.storage.error import StorageNotFoundError
from acapy_agent.wallet.error import WalletError, WalletNotFoundError
from acapy_agent.wallet.util import b64_to_bytes
from aiohttp import web
@@ -17,12 +18,50 @@
from ..cred_processor import CredProcessors
from ..did_utils import retrieve_or_create_did_jwk
from ..jwt import jwt_sign
+from ..models.issuer_config import IssuerConfiguration
from ..models.supported_cred import SupportedCredential
-from ..utils import get_first_auth_server
LOGGER = logging.getLogger(__name__)
+async def _load_issuer_context(session, wallet_id: str | None):
+ """Load IssuerConfiguration and the first AS entry with a public_url."""
+ try:
+ cfg = await IssuerConfiguration.retrieve_by_id(
+ session, wallet_id or "default-wallet"
+ )
+ except StorageNotFoundError:
+ cfg = None
+ servers = (cfg.authorization_servers if cfg else None) or []
+ auth = next((a for a in servers if a.get("public_url")), None)
+ return cfg, auth
+
+
+def _build_cred_configs(credentials_supported, processors) -> dict:
+ """Build the credential_configurations_supported map."""
+ result: dict = {}
+ for supported in credentials_supported:
+ try:
+ issuer = processors.issuer_for_format(supported.format)
+ except Exception:
+ issuer = None
+ result[supported.identifier] = supported.to_issuer_metadata(issuer=issuer)
+ return result
+
+
+def _apply_overlay(
+ metadata: dict, issuer_config, cred_configs: dict, base_url: str, enable_nonce: bool
+) -> None:
+ """Overlay DB config (keeping server-derived cred_configs) and gate nonce_endpoint."""
+ if issuer_config:
+ metadata.update(issuer_config.issuer_metadata(base_url))
+ metadata["credential_configurations_supported"] = cred_configs
+ if enable_nonce:
+ metadata.setdefault("nonce_endpoint", f"{base_url}/nonce")
+ else:
+ metadata.pop("nonce_endpoint", None)
+
+
class BatchCredentialIssuanceSchema(OpenAPISchema):
"""Batch credential issuance schema."""
@@ -51,8 +90,10 @@ class CredentialIssuerMetadataSchema(OpenAPISchema):
required=False,
metadata={"description": "The nonce endpoint."},
)
- credential_configurations_supported = fields.List(
- fields.Dict(),
+ credential_configurations_supported = fields.Dict(
+ keys=fields.Str(),
+ values=fields.Dict(),
+ required=True,
metadata={"description": "The supported credentials."},
)
batch_credential_issuance = fields.Nested(
@@ -68,8 +109,8 @@ async def credential_issuer_metadata(request: web.Request):
"""Credential issuer metadata endpoint.
If the client sends `Accept: application/jwt`, the metadata is returned as
- a signed JWT (OID4VCI 1.0 §11.2.2 — signed metadata). Otherwise, plain
- JSON is returned.
+ a signed JWT (OID4VCI 1.0 §12.2.2 — metadata retrieval; §12.2.3 defines
+ the signed metadata format). Otherwise, plain JSON is returned.
"""
context: AdminRequestContext = request["context"]
config = Config.from_settings(context.settings)
@@ -78,42 +119,34 @@ async def credential_issuer_metadata(request: web.Request):
async with context.session() as session:
# TODO If there's a lot, this will be a problem
credentials_supported = await SupportedCredential.query(session)
- auth_server = await get_first_auth_server(session, context.profile)
wallet_id = request.match_info.get("wallet_id")
subpath = f"/tenant/{wallet_id}" if wallet_id else ""
+ issuer_config, auth_server = await _load_issuer_context(session, wallet_id)
+
metadata: dict[str, Any] = {"credential_issuer": f"{public_url}{subpath}"}
if auth_server:
- # Point directly at the auth server's public URL so the wallet
- # performs OAuth discovery and token requests against the auth server.
metadata["authorization_servers"] = [auth_server["public_url"]]
else:
- # When ACA-Py is its own authorization server (no external auth server),
- # include token_endpoint directly in the credential issuer metadata.
- # This is technically an extension beyond OID4VCI spec §11.2.1 (which
- # says token_endpoint belongs in AS metadata via /.well-known/oauth-
- # authorization-server), but some wallets (e.g. waltid) read
- # token_endpoint from resolveCIProviderMetadata() and NPE if absent,
- # rather than performing AS discovery.
+ # Extension for wallets (e.g. waltid) that read token_endpoint from
+ # credential issuer metadata instead of doing AS discovery.
metadata["token_endpoint"] = f"{public_url}{subpath}/token"
metadata["credential_endpoint"] = f"{public_url}{subpath}/credential"
metadata["notification_endpoint"] = f"{public_url}{subpath}/notification"
- if config.enable_nonce_endpoint:
- metadata["nonce_endpoint"] = f"{public_url}{subpath}/nonce"
- processors = context.inject(CredProcessors)
- cred_configs = {}
- for supported in credentials_supported:
- try:
- issuer = processors.issuer_for_format(supported.format)
- except Exception:
- issuer = None
- cred_configs[supported.identifier] = supported.to_issuer_metadata(
- issuer=issuer
- )
+ cred_configs = _build_cred_configs(
+ credentials_supported, context.inject(CredProcessors)
+ )
metadata["credential_configurations_supported"] = cred_configs
- # OID4VCI 1.0 §11.2.2: if client requests signed metadata, sign and return
- # the metadata document as a JWT with Content-Type: application/jwt.
+ _apply_overlay(
+ metadata,
+ issuer_config,
+ cred_configs,
+ f"{public_url}{subpath}",
+ config.enable_nonce_endpoint,
+ )
+
+ # OID4VCI 1.0 §12.2.2/§12.2.3: signed metadata as JWT with application/jwt.
accept = request.headers.get("Accept", "")
vm: str | None = None
jwk_public: dict | None = None
@@ -122,7 +155,6 @@ async def credential_issuer_metadata(request: web.Request):
async with context.profile.session() as sig_session:
jwk_info = await retrieve_or_create_did_jwk(sig_session)
vm = f"{jwk_info.did}#0"
- # Decode the public JWK from the did:jwk DID.
# did:jwk: — reverse _create_default_did.
jwk_encoded = jwk_info.did[len("did:jwk:") :]
jwk_public = json.loads(b64_to_bytes(jwk_encoded, urlsafe=True).decode())
@@ -130,8 +162,9 @@ async def credential_issuer_metadata(request: web.Request):
LOGGER.warning("Cannot sign metadata JWT: %s", err)
if "application/jwt" in accept and vm and jwk_public:
- # Build JWT payload: include all metadata fields + required JWT claims
- issuer_url = f"{public_url}{subpath}"
+ # §12.2.3: `sub` MUST match the Credential Issuer Identifier — use the
+ # value in metadata so a DB-overridden credential_issuer is honored.
+ issuer_url = metadata["credential_issuer"]
payload = {
**metadata,
"iss": issuer_url,
@@ -141,13 +174,11 @@ async def credential_issuer_metadata(request: web.Request):
try:
signed_jwt = await jwt_sign(
context.profile,
- # Include the public JWK in the header — OID4VCI §11.2.2 / RFC 7515
- # requires either `jwk` or `x5c` for signed issuer metadata.
- # The `jwk` must have `kid` matching the JWT header's `kid` so
- # the conformance suite can locate the correct key.
+ # OID4VCI §12.2.3 requires typ=openidvci-issuer-metadata+jwt.
+ # `jwk` in the header conveys the key (RFC 7515 §4.1.3).
headers={
"jwk": {**jwk_public, "kid": vm},
- "typ": "openid-credential-issuer",
+ "typ": "openidvci-issuer-metadata+jwt",
},
payload=payload,
verification_method=vm,
@@ -181,61 +212,43 @@ async def openid_configuration(request: web.Request):
async with context.session() as session:
# TODO If there's a lot, this will be a problem
credentials_supported = await SupportedCredential.query(session)
- auth_server = await get_first_auth_server(session, context.profile)
wallet_id = request.match_info.get("wallet_id")
subpath = f"/tenant/{wallet_id}" if wallet_id else ""
base_url = f"{public_url}{subpath}"
+ issuer_config, auth_server = await _load_issuer_context(session, wallet_id)
- processors = context.inject(CredProcessors)
- cred_configs = {}
- for supported in credentials_supported:
- try:
- issuer = processors.issuer_for_format(supported.format)
- except Exception:
- issuer = None
- cred_configs[supported.identifier] = supported.to_issuer_metadata(
- issuer=issuer
- )
+ cred_configs = _build_cred_configs(
+ credentials_supported, context.inject(CredProcessors)
+ )
- # Combined OIDC Discovery + OID4VCI metadata
metadata: dict[str, Any] = {
- # OIDC Discovery fields (RFC 8414 / OIDC Discovery required fields)
"issuer": base_url,
- # authorization_endpoint is required by CheckServerConfiguration in the
- # OIDF conformance suite (condition.common.CheckServerConfiguration checks
- # for "authorization_endpoint", "token_endpoint", and "issuer").
- # For pre-authorized_code flow the authorization endpoint is not invoked,
- # but it must be advertised in the AS metadata.
+ # Required by OIDF CheckServerConfiguration; unused in pre-auth flow.
"authorization_endpoint": f"{base_url}/authorize",
- "token_endpoint": f"{base_url}/token",
"response_types_supported": ["code"],
- # DPoP support - required by HAIP profile (DPOP-5.1).
- # Advertise the algorithms supported for DPoP proof JWTs.
"dpop_signing_alg_values_supported": ["ES256", "ES384", "ES512"],
- # OAuth 2.0 AS Metadata fields
"grant_types_supported": [
"urn:ietf:params:oauth:grant-type:pre-authorized_code"
],
- # RFC 9396 Rich Authorization Requests — advertise the authorization_details
- # type(s) supported (required by OID4VCI HAIP AS metadata validation).
"authorization_details_types_supported": ["openid_credential"],
- # OID4VCI fields
"credential_issuer": base_url,
"credential_endpoint": f"{base_url}/credential",
"notification_endpoint": f"{base_url}/notification",
"credential_configurations_supported": cred_configs,
}
- if config.enable_nonce_endpoint:
- # OID4VCI nonce endpoint for server-generated nonces (HAIP required).
- # Wallets call this before building a credential proof to get a fresh
- # nonce that ACA-Py validates in the JWT proof `nonce` claim.
- metadata["nonce_endpoint"] = f"{base_url}/nonce"
+ # token_endpoint belongs to the AS; only advertise when ACA-Py is its own AS.
+ if not auth_server:
+ metadata["token_endpoint"] = f"{base_url}/token"
if auth_server:
metadata["authorization_servers"] = [auth_server["public_url"]]
+ _apply_overlay(
+ metadata, issuer_config, cred_configs, base_url, config.enable_nonce_endpoint
+ )
+
LOGGER.debug("OPENID CONFIG: %s", metadata)
return web.json_response(metadata)
diff --git a/oid4vc/oid4vc/routes/helpers.py b/oid4vc/oid4vc/routes/helpers.py
index 0b7ca5dfc..85bfdb286 100644
--- a/oid4vc/oid4vc/routes/helpers.py
+++ b/oid4vc/oid4vc/routes/helpers.py
@@ -80,17 +80,22 @@ async def _parse_cred_offer(context: AdminRequestContext, exchange_id: str) -> d
supported = await SupportedCredential.retrieve_by_id(
session, record.supported_cred_id
)
- auth_server = await get_first_auth_server(session, context.profile)
- record.code = await _create_pre_auth_code(
- context.profile,
- config,
- auth_server,
- record.refresh_id,
- supported.identifier,
- record.pin,
- )
- record.state = OID4VCIExchangeRecord.STATE_OFFER_CREATED
- await record.save(session, reason="Credential offer created")
+ if record.state == OID4VCIExchangeRecord.STATE_CREATED:
+ auth_server = await get_first_auth_server(session, context.profile)
+ record.code = await _create_pre_auth_code(
+ context.profile,
+ config,
+ auth_server,
+ record.refresh_id,
+ supported.identifier,
+ record.pin,
+ )
+ record.state = OID4VCIExchangeRecord.STATE_OFFER_CREATED
+ await record.save(session, reason="Credential offer created")
+ elif record.state != OID4VCIExchangeRecord.STATE_OFFER_CREATED:
+ raise web.HTTPBadRequest(
+ reason="This exchange record has already been used."
+ )
except (StorageError, BaseModelError) as err:
raise web.HTTPBadRequest(reason=err.roll_up) from err
diff --git a/oid4vc/oid4vc/tests/routes/test_public_routes.py b/oid4vc/oid4vc/tests/routes/test_public_routes.py
index 63734a0c2..135a04348 100644
--- a/oid4vc/oid4vc/tests/routes/test_public_routes.py
+++ b/oid4vc/oid4vc/tests/routes/test_public_routes.py
@@ -80,6 +80,228 @@ async def test_issuer_metadata(context: AdminRequestContext, req: web.Request):
)
+@pytest.mark.asyncio
+async def test_metadata_overlays_issuer_configuration(
+ context: AdminRequestContext, req: web.Request
+):
+ """IssuerConfiguration values override default generated metadata fields.
+
+ Also verifies that when an external authorization server is configured, no
+ `token_endpoint` is advertised in either metadata document.
+ """
+
+ wallet_id = req.match_info.get("wallet_id")
+ display = [
+ {
+ "name": "University Credential",
+ "locale": "en-US",
+ "logo": {
+ "uri": "https://exampleuniversity.com/public/logo.png",
+ "alt_text": "a square logo of a university",
+ },
+ }
+ ]
+ request_encryption = {
+ "keys": [
+ {
+ "kty": "EC",
+ "crv": "P-256",
+ "x": "f83OJ3D2xF4Jqk8rVqYf5UEoR2L7iB42t1R6kzjzA6o",
+ "y": "x_FEzRu9yQ1rZtQxCkVwYg1oHc3mG5m0kYqf9u0Qf6A",
+ "use": "enc",
+ "alg": "ECDH-ES",
+ "kid": "ec-p256-enc-1",
+ }
+ ]
+ }
+ response_encryption = {
+ "alg_values_supported": ["ECDH-ES", "ECDH-ES+A256KW"],
+ "enc_values_supported": ["A256GCM", "A128GCM"],
+ "encryption_required": True,
+ "zip_values_supported": ["DEF"],
+ }
+ async with context.session() as session:
+ issuer_config = IssuerConfiguration(
+ configuration_id=wallet_id,
+ new_with_id=True,
+ credential_issuer="https://issuer.example.com",
+ authorization_servers=[
+ {
+ "public_url": "https://auth.example.com",
+ "private_url": "https://auth.internal",
+ "auth_type": "client_secret_basic",
+ "client_credentials": {"client_id": "abc", "client_secret": "xyz"},
+ }
+ ],
+ credential_endpoint="https://issuer.example.com/custom-credential",
+ nonce_endpoint="https://issuer.example.com/custom-nonce",
+ deferred_credential_endpoint="https://issuer.example.com/deferred",
+ notification_endpoint="https://issuer.example.com/notify",
+ credential_request_encryption=request_encryption,
+ credential_response_encryption=response_encryption,
+ batch_credential_issuance={"batch_size": 100},
+ display=display,
+ )
+ await issuer_config.save(session)
+
+ supported = SupportedCredential(
+ format="jwt_vc_json",
+ identifier="StoredConfigCredential",
+ credential_metadata={"claims": [{"path": ["name"]}]},
+ )
+ await supported.save(session)
+
+ for endpoint in (
+ test_module.credential_issuer_metadata,
+ test_module.openid_configuration,
+ ):
+ with patch.object(_metadata_module, "web", autospec=True) as mock_web:
+ await endpoint(req)
+ metadata = mock_web.json_response.call_args.args[0]
+
+ # DB overrides the mandatory fields
+ assert metadata["credential_issuer"] == "https://issuer.example.com"
+ assert (
+ metadata["credential_endpoint"]
+ == "https://issuer.example.com/custom-credential"
+ )
+ # authorization_servers normalized to public URLs (§12.2.4)
+ assert metadata["authorization_servers"] == ["https://auth.example.com"]
+ # All configured optional fields flow through the overlay
+ assert metadata["nonce_endpoint"] == "https://issuer.example.com/custom-nonce"
+ assert (
+ metadata["deferred_credential_endpoint"]
+ == "https://issuer.example.com/deferred"
+ )
+ assert metadata["notification_endpoint"] == "https://issuer.example.com/notify"
+ assert metadata["credential_request_encryption"] == request_encryption
+ assert metadata["credential_response_encryption"] == response_encryption
+ assert metadata["batch_credential_issuance"] == {"batch_size": 100}
+ assert metadata["display"] == display
+ # server-derived credentials are not clobbered by the overlay
+ assert "StoredConfigCredential" in metadata["credential_configurations_supported"]
+ # external AS present -> no token_endpoint at the credential issuer
+ assert "token_endpoint" not in metadata
+
+
+@pytest.mark.asyncio
+async def test_authorization_servers_drops_entries_without_public_url(
+ context: AdminRequestContext, req: web.Request
+):
+ """§12.2.4: authorization_servers is an array of strings, non-empty.
+
+ Filter out DB entries missing `public_url` so we never publish `null`.
+ """
+ wallet_id = req.match_info.get("wallet_id")
+ async with context.session() as session:
+ issuer_config = IssuerConfiguration(
+ configuration_id=wallet_id,
+ new_with_id=True,
+ authorization_servers=[
+ {"private_url": "https://intra.example.com"}, # no public_url
+ {"public_url": "https://auth.example.com"},
+ ],
+ )
+ await issuer_config.save(session)
+
+ with patch.object(_metadata_module, "web", autospec=True) as mock_web:
+ await test_module.credential_issuer_metadata(req)
+ metadata = mock_web.json_response.call_args.args[0]
+ assert metadata["authorization_servers"] == ["https://auth.example.com"]
+ assert None not in metadata["authorization_servers"]
+
+
+@pytest.mark.asyncio
+async def test_signed_metadata_uses_spec_typ_header(
+ monkeypatch, context: AdminRequestContext, req: web.Request
+):
+ """§12.2.3: signed metadata JWT MUST use typ=openidvci-issuer-metadata+jwt.
+
+ Also verifies `sub` matches the (possibly DB-overridden) credential_issuer.
+ """
+ from types import SimpleNamespace
+
+ req.headers = {"Accept": "application/jwt"}
+
+ wallet_id = req.match_info.get("wallet_id")
+ async with context.session() as session:
+ await IssuerConfiguration(
+ configuration_id=wallet_id,
+ new_with_id=True,
+ credential_issuer="https://issuer.example.com",
+ ).save(session)
+
+ monkeypatch.setattr(
+ _metadata_module,
+ "retrieve_or_create_did_jwk",
+ AsyncMock(
+ return_value=SimpleNamespace(
+ # did:jwk:
+ did="did:jwk:eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6IkFBIn0"
+ )
+ ),
+ )
+ captured = {}
+
+ async def fake_sign(profile, headers, payload, verification_method):
+ captured["headers"] = headers
+ captured["payload"] = payload
+ return "signed.jwt.value"
+
+ monkeypatch.setattr(_metadata_module, "jwt_sign", fake_sign)
+
+ with patch.object(_metadata_module, "web", autospec=True) as mock_web:
+ mock_web.Response.return_value = MagicMock()
+ await test_module.credential_issuer_metadata(req)
+
+ assert captured["headers"]["typ"] == "openidvci-issuer-metadata+jwt"
+ # §12.2.3: sub REQUIRED = Credential Issuer Identifier; iat REQUIRED.
+ assert captured["payload"]["sub"] == "https://issuer.example.com"
+ assert captured["payload"]["sub"] == captured["payload"]["credential_issuer"]
+ assert isinstance(captured["payload"]["iat"], int)
+
+
+@pytest.mark.asyncio
+async def test_metadata_suppresses_nonce_endpoint_when_disabled(
+ monkeypatch, context: AdminRequestContext, req: web.Request
+):
+ """DB-configured `nonce_endpoint` is suppressed when local nonce is off.
+
+ PoP validation uses direct c_nonce comparison when `enable_nonce_endpoint`
+ is False, so publishing a nonce endpoint would advertise a flow the server
+ cannot validate. The overlay is preserved for every other DB field.
+ """
+ wallet_id = req.match_info.get("wallet_id")
+ async with context.session() as session:
+ issuer_config = IssuerConfiguration(
+ configuration_id=wallet_id,
+ new_with_id=True,
+ nonce_endpoint="https://issuer.example.com/custom-nonce",
+ display=[{"name": "Example Issuer", "locale": "en"}],
+ )
+ await issuer_config.save(session)
+
+ monkeypatch.setattr(
+ _metadata_module.Config,
+ "from_settings",
+ lambda settings: MagicMock(
+ endpoint="http://localhost:8020",
+ enable_nonce_endpoint=False,
+ ),
+ )
+
+ for endpoint in (
+ test_module.credential_issuer_metadata,
+ test_module.openid_configuration,
+ ):
+ with patch.object(_metadata_module, "web", autospec=True) as mock_web:
+ await endpoint(req)
+ metadata = mock_web.json_response.call_args.args[0]
+ assert "nonce_endpoint" not in metadata
+ # Other DB overlay fields still flow through
+ assert metadata["display"] == [{"name": "Example Issuer", "locale": "en"}]
+
+
@pytest.mark.asyncio
async def test_get_token(context: AdminRequestContext, req: web.Request):
"""Test token issuance endpoint."""
diff --git a/oid4vc/oid4vc/tests/test_routes.py b/oid4vc/oid4vc/tests/test_routes.py
index 7dc1e30e1..7df99b697 100644
--- a/oid4vc/oid4vc/tests/test_routes.py
+++ b/oid4vc/oid4vc/tests/test_routes.py
@@ -85,7 +85,7 @@ async def test_parse_cred_offer(monkeypatch, context):
mock_record.pin = "1234"
mock_record.refresh_id = "refresh_id"
mock_record.code = None
- mock_record.state = None
+ mock_record.state = OID4VCIExchangeRecord.STATE_CREATED
mock_record.save = AsyncMock()
monkeypatch.setattr(
"oid4vc.routes.helpers.OID4VCIExchangeRecord.retrieve_by_id",
@@ -101,6 +101,9 @@ async def test_parse_cred_offer(monkeypatch, context):
monkeypatch.setattr(
"oid4vc.routes.helpers._create_pre_auth_code", AsyncMock(return_value="code123")
)
+ monkeypatch.setattr(
+ "oid4vc.routes.helpers.get_first_auth_server", AsyncMock(return_value=None)
+ )
offer = await _parse_cred_offer(context, "exchange_id")
assert offer["credential_issuer"].startswith("http://localhost:8020")
assert (