Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions oid4vc/auth_server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
44 changes: 27 additions & 17 deletions oid4vc/auth_server/admin/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
20 changes: 15 additions & 5 deletions oid4vc/auth_server/admin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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))
Expand Down Expand Up @@ -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"])

Expand All @@ -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"},
)


Expand Down
23 changes: 23 additions & 0 deletions oid4vc/auth_server/admin/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
35 changes: 28 additions & 7 deletions oid4vc/auth_server/admin/routers/internal.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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),
Expand All @@ -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),
Expand All @@ -35,11 +39,28 @@ 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(...),
db: AsyncSession = Depends(get_db_session),
):
"""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}
11 changes: 10 additions & 1 deletion oid4vc/auth_server/admin/routers/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])

Expand All @@ -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}
Loading