Skip to content
Merged
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
18 changes: 16 additions & 2 deletions oid4vc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,21 @@ The Plugin expects the following configuration options. These options can either
- `credential_issuer` endpoint, seen in the Credential Offer
- `OID4VCI_CRED_HANDLER` or `oid4vci.cred_handler`
- Dict of credential handlers. e.g. `{"jwt_vc_json": "jwt_vc_json"}`
- `OID4VCI_ENABLE_NONCE_ENDPOINT` or `oid4vci.enable_nonce_endpoint`
- When `true` (default), the `/nonce` endpoint is published and nonce validation uses DB-based single-use redemption. When `false`, the `/nonce` endpoint is not registered and nonce validation relies on `c_nonce` from the access token (either via auth server introspect or the exchange record).

#### Nonce Handling

The plugin supports four deployment scenarios depending on whether an external authorization server is configured and whether the nonce endpoint is enabled:

| Scenario | Token Source | Nonce Validation |
|---|---|---|
| External auth server + nonce endpoint | Auth server (introspect) | DB redemption (`Nonce.redeem_by_value`) |
| External auth server + no nonce endpoint | Auth server (introspect) | `c_nonce` from introspect payload vs proof JWT |
| Local token endpoint + nonce endpoint | Local JWT | DB redemption |
| Local token endpoint + no nonce endpoint | Local JWT | `c_nonce` from exchange record vs proof JWT |

When an external auth server is configured, the wallet obtains its token from the auth server's token endpoint. If the auth server provides a `c_nonce` in the token response, the wallet will typically use that nonce directly rather than calling the `/nonce` endpoint. To use DB-based nonce redemption with an auth server, disable nonce generation on the auth server side so the wallet calls the plugin's `/nonce` endpoint.

#### Authorization Server (Per-Tenant)

Expand Down Expand Up @@ -476,9 +491,8 @@ Without this, importing `mso_mdoc` will fail with `ModuleNotFoundError: No modul

- `ldp_vc`
- Authorization Code Flow
- GET /.well-known/openid-configuration
- GET /.well-known/oauth-authorization-server
- Batch Credential Issuance
- Full DPoP support (RFC 9449) — DPoP tokens are accepted but the proof is not cryptographically verified
- We're limited to DID Methods that ACA-Py supports for issuance (more can be added by Plugin, e.g. DID Web); `did:sov`, `did:key`

[oid4vci]: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html
9 changes: 8 additions & 1 deletion oid4vc/auth_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@
- Prereqs: Python 3.12, PostgreSQL, Poetry
- Install dependencies: `poetry install`
- Configure env files (examples below)
- Run `alembic/sql/init.sql` to create the admin user and database
- Run `alembic/sql/init.sql` to create the admin user and database:
```bash
psql -h <host> -U postgres \
-v ADMIN_DB_USER=auth_server_admin \
-v ADMIN_DB_PASSWORD=<password> \
-v ADMIN_DB_NAME=auth_server_admin \
-f alembic/sql/init.sql
```
- Initialize Admin DB schema (Alembic): `python alembic/admin/migrate.py`
- Run Admin API (e.g., port 9000): `uvicorn admin.main:app --reload --port 9000`
- Run Tenant API (e.g., port 9001): `uvicorn tenant.main:app --reload --port 9001`
Expand Down
11 changes: 10 additions & 1 deletion oid4vc/auth_server/alembic/sql/init.sql
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
--
-- create :ADMIN_DB_USER user
-- Run with:
-- psql -v ADMIN_DB_USER=... -v ADMIN_DB_PASSWORD=... -v ADMIN_DB_NAME=... \
-- -f alembic/sql/init.sql
--
CREATE ROLE :ADMIN_DB_USER
WITH
LOGIN PASSWORD :ADMIN_DB_PASSWORD NOSUPERUSER CREATEDB CREATEROLE INHERIT NOBYPASSRLS NOREPLICATION;
LOGIN PASSWORD :'ADMIN_DB_PASSWORD' NOSUPERUSER CREATEDB CREATEROLE INHERIT NOBYPASSRLS NOREPLICATION;

--
-- create :ADMIN_DB_NAME database
--
CREATE DATABASE :ADMIN_DB_NAME OWNER :ADMIN_DB_USER ENCODING 'UTF8' TEMPLATE template0;

--
-- grant permissions to :ADMIN_DB_USER
--
GRANT CONNECT,
CREATE ON DATABASE :ADMIN_DB_NAME TO :ADMIN_DB_USER;
1 change: 0 additions & 1 deletion oid4vc/auth_server/docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ until pg_isready -h "$ADMIN_DB_HOST" -U "postgres"; do
done

# Only run DB init script if the admin database does not exist.
# TODO parmaterize the DB admin user and password. Not the same as ADMIN_DB_USER/PASSWORD which are specific to the auth server admin.
if ! PGPASSWORD="postgres" psql -h "$ADMIN_DB_HOST" -U "postgres" -lqt | cut -d \| -f 1 | grep -qw "$ADMIN_DB_NAME"; then
echo "Database $ADMIN_DB_NAME does not exist. Running init.sql..."
PGPASSWORD="postgres" psql -h "$ADMIN_DB_HOST" -U "postgres" \
Expand Down
6 changes: 2 additions & 4 deletions oid4vc/demo/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ services:
STATUS_LIST_SIZE: 131072
STATUS_LIST_SHARD_SIZE: 131072
#STATUS_LIST_PUBLIC_URI: https://localhost:8082/tenant/{tenant_id}/status/{list_number} Set in entrypoint.sh
STATUS_LIST_FILE_PATH: /tmp/bitstring/{tenant_id}/{list_number}
#STATUS_LIST_FILE_PATH: /tmp/bitstring/{tenant_id}/{list_number}
entrypoint: >
/bin/sh -c '/entrypoint.sh aca-py "$$@"' --
# Note: --no-transport should be added to start but currently blocks webhooks - fix in ACA-Py main.
Expand Down Expand Up @@ -139,7 +139,6 @@ services:
issuer:
condition: service_healthy


#setup Auth Server.
#ADMIN_MANAGE_AUTH_TOKEN is sourced from the .env as it is shared with demo-app.
#Other environment variables are set explicitly here to show a working configuration.
Expand All @@ -152,8 +151,6 @@ services:
ports:
- "9000:9000"
- "9001:9001"
volumes:
- ./init.sql:/usr/src/app/alembic/sql/init.sql:ro
environment:
ADMIN_DB_HOST: db
ADMIN_DB_USER: auth_server_admin
Expand All @@ -174,6 +171,7 @@ services:
TENANT_DB_HOST: db
TENANT_DB_PORT: 5432
TENANT_PROXY_TRUSTED_HOSTS: "*"
TENANT_INCLUDE_NONCE: "false"
#TENANT_ISSUER_BASE_URL: Set in entrypoint.sh
depends_on:
db:
Expand Down
20 changes: 0 additions & 20 deletions oid4vc/demo/init.sql

This file was deleted.

33 changes: 28 additions & 5 deletions oid4vc/integration/oid4vci_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ class IssuerMetadata:
credential_endpoint: str
token_endpoint: str
credential_configurations_supported: dict[str, Any]
nonce_endpoint: Optional[str] = None


@dataclass
class TokenParams:
"""Token Parameters."""

access_token: str
nonce: str
nonce: Optional[str] = None


class OpenID4VCIClient:
Expand Down Expand Up @@ -107,6 +108,7 @@ async def get_issuer_metadata(self, issuer_url: str):
metadata["credential_endpoint"],
token_endpoint,
metadata["credential_configurations_supported"],
nonce_endpoint=metadata.get("nonce_endpoint"),
)

async def request_token(self, offer: CredentialOffer, metadata: IssuerMetadata):
Expand All @@ -124,11 +126,25 @@ async def request_token(self, offer: CredentialOffer, metadata: IssuerMetadata):
) as resp:
token = await resp.json()

# OID4VCI 1.0: c_nonce is only present in the token response when the
# issuer does NOT publish a Nonce Endpoint. Otherwise it's fetched
# per-request from POST /nonce (see request_credential).
return TokenParams(
token["access_token"],
token["c_nonce"],
token.get("c_nonce"),
)

async def fetch_nonce(self, nonce_endpoint: str) -> str:
"""Fetch a fresh nonce from the Nonce Endpoint (OID4VCI 1.0 §7)."""
async with ClientSession() as session:
async with session.post(nonce_endpoint) as resp:
if resp.status != 200:
raise ValueError(
f"Error fetching nonce: {resp.status} {await resp.text()}"
)
body = await resp.json()
return body["c_nonce"]

async def request_credential(
self,
holder_did: str,
Expand All @@ -152,9 +168,16 @@ async def request_credential(
if credential_configuration_id is None:
raise ValueError("No credential_configuration_id in offer or metadata")

proofs = await crypto.proof_of_possession(
key, offer.credential_issuer, token.nonce
)
nonce = token.nonce
if nonce is None:
if not metadata.nonce_endpoint:
raise ValueError(
"Token response has no c_nonce and issuer does not publish "
"nonce_endpoint"
)
nonce = await self.fetch_nonce(metadata.nonce_endpoint)

proofs = await crypto.proof_of_possession(key, offer.credential_issuer, nonce)
if isinstance(proofs.get("jwt"), str):
proofs["jwt"] = [proofs["jwt"]]

Expand Down
12 changes: 12 additions & 0 deletions oid4vc/oid4vc/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ class Config:
# Reads OID4VP_ENDPOINT env var; falls back to OID4VCI endpoint if not set.
oid4vp_endpoint: str | None = None
status_handler: str | None = None
# When True, the /nonce endpoint is published and nonces are managed locally.
# Nonce validation uses DB-based redemption, ignoring c_nonce from access tokens.
enable_nonce_endpoint: bool = True

@classmethod
def from_settings(cls, settings: BaseSettings) -> "Config":
Expand All @@ -47,6 +50,14 @@ def from_settings(cls, settings: BaseSettings) -> "Config":
status_handler = plugin_settings.get("status_handler") or getenv(
"OID4VCI_STATUS_HANDLER"
)
# Enable/disable the /nonce endpoint. Defaults to True.
enable_nonce_raw = plugin_settings.get("enable_nonce_endpoint")
if enable_nonce_raw is None:
enable_nonce_raw = getenv("OID4VCI_ENABLE_NONCE_ENDPOINT", "true")
if str(enable_nonce_raw).lower() not in ("true", "false"):
raise ConfigError("enable_nonce_endpoint", "OID4VCI_ENABLE_NONCE_ENDPOINT")
enable_nonce_endpoint = str(enable_nonce_raw).lower() == "true"

if not host:
raise ConfigError("host", "OID4VCI_HOST")
if not port:
Expand Down Expand Up @@ -75,4 +86,5 @@ def replacer(match):
endpoint,
oid4vp_endpoint=oid4vp_endpoint,
status_handler=status_handler,
enable_nonce_endpoint=enable_nonce_endpoint,
)
7 changes: 4 additions & 3 deletions oid4vc/oid4vc/public_routes/credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,10 @@ async def _issue_cred_inner(context, token_result, refresh_id, req_body):
f"{supported.identifier} is not authorized by the token.",
)

# c_nonce may be None when the OID4VCI 1.0 /nonce endpoint is used.
# handle_proof_of_posession handles c_nonce=None by calling Nonce.redeem_by_value,
# which validates nonces issued by the /nonce endpoint with replay protection.
# When enable_nonce_endpoint=False, c_nonce is needed for PoP validation.
# Sources (in priority order):
# 1. token introspect payload (external auth server)
# 2. exchange record (local token endpoint stores it at issuance time)
c_nonce = token_result.payload.get("c_nonce") or ex_record.nonce

# Normalize proof: accept both 'proof' (singular, draft spec) and
Expand Down
11 changes: 7 additions & 4 deletions oid4vc/oid4vc/public_routes/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ async def credential_issuer_metadata(request: web.Request):
metadata["token_endpoint"] = f"{public_url}{subpath}/token"
metadata["credential_endpoint"] = f"{public_url}{subpath}/credential"
metadata["notification_endpoint"] = f"{public_url}{subpath}/notification"
metadata["nonce_endpoint"] = f"{public_url}{subpath}/nonce"
if config.enable_nonce_endpoint:
metadata["nonce_endpoint"] = f"{public_url}{subpath}/nonce"
processors = context.inject(CredProcessors)
cred_configs = {}
for supported in credentials_supported:
Expand Down Expand Up @@ -223,12 +224,14 @@ async def openid_configuration(request: web.Request):
"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.
"nonce_endpoint": f"{base_url}/nonce",
"credential_configurations_supported": cred_configs,
}
metadata["nonce_endpoint"] = f"{base_url}/nonce"

if auth_server:
metadata["authorization_servers"] = [auth_server["public_url"]]
Expand Down
28 changes: 1 addition & 27 deletions oid4vc/oid4vc/public_routes/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from marshmallow import fields

from ..models.exchange import OID4VCIExchangeRecord
from .token import check_token, handle_proof_of_posession
from .token import check_token

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -104,32 +104,6 @@ async def _receive_notification_inner(request: web.Request) -> web.Response:

LOGGER.debug("Notification request: %s", body)

# OID4VCI 1.0 §11: if the request body includes a proof or proofs.jwt, the
# notification endpoint MUST validate the JWT proof and check for nonce replay.
# (handle_proof_of_posession with c_nonce=None uses Nonce.redeem_by_value.)
proof_value = None
if "proof" in body and isinstance(body["proof"], dict):
proof_value = body["proof"]
elif "proofs" in body and isinstance(body.get("proofs"), dict):
jwt_list = body["proofs"].get("jwt", [])
if jwt_list:
proof_value = {"proof_type": "jwt", "jwt": jwt_list[0]}

if proof_value:
try:
# c_nonce=None: replay protection via Nonce.redeem_by_value.
await handle_proof_of_posession(context.profile, proof_value, c_nonce=None)
except web.HTTPBadRequest as proof_exc:
# Propagate proof errors (invalid_nonce, invalid_proof) as-is
try:
err_body = json.loads(proof_exc.text or "{}")
except Exception:
err_body = {
"error": "invalid_proof",
"error_description": proof_exc.reason,
}
return web.json_response(err_body, status=400)

# Manually validate required fields — OID4VCI 1.0 §11 requires 400 for errors.
notification_id = body.get("notification_id")
event = body.get("event")
Expand Down
19 changes: 10 additions & 9 deletions oid4vc/oid4vc/public_routes/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from acapy_agent.config.injection_context import InjectionContext
from aiohttp import web

from ..config import Config
from ..status_handler import StatusHandler
from .credential import dereference_cred_offer, issue_cred
from .metadata import credential_issuer_metadata, openid_configuration
Expand Down Expand Up @@ -36,6 +37,7 @@ async def register(app: web.Application, multitenant: bool, context: InjectionCo

Adds the subpath with Wallet ID as a path parameter if multitenant is True.
"""
config = Config.from_settings(context.settings)
subpath = "/tenant/{wallet_id}" if multitenant else ""
routes = [
web.get(
Expand All @@ -44,26 +46,26 @@ async def register(app: web.Application, multitenant: bool, context: InjectionCo
allow_head=False,
),
web.get(
f"{subpath}/.well-known/openid-credential-issuer",
f"/.well-known/openid-credential-issuer{subpath}",
credential_issuer_metadata,
allow_head=False,
),
# OID4VCI 1.0 spec uses underscore; dash variant is kept for compatibility
# OID4VCI 1.0 spec uses dash; underscore variant is kept for compatibility
web.get(
f"{subpath}/.well-known/openid_credential_issuer",
f"/.well-known/openid_credential_issuer{subpath}",
credential_issuer_metadata_deprecated,
allow_head=False,
),
web.get(
f"{subpath}/.well-known/openid-configuration",
f"/.well-known/openid-configuration{subpath}",
openid_configuration,
allow_head=False,
),
# RFC 8414 Authorization Server Metadata endpoint - required by OID4VCI
# conformance suite (VCIFetchOAuthorizationServerMetadata). ACA-Py serves
# the same content as /.well-known/openid-configuration.
web.get(
f"{subpath}/.well-known/oauth-authorization-server",
f"/.well-known/oauth-authorization-server{subpath}",
openid_configuration,
allow_head=False,
),
Expand All @@ -72,13 +74,12 @@ async def register(app: web.Application, multitenant: bool, context: InjectionCo
web.post(f"{subpath}/token", token),
web.post(f"{subpath}/notification", receive_notification),
web.post(f"{subpath}/credential", issue_cred),
# OID4VCI nonce endpoint — provides server-generated nonces for proof-of-
# possession in HAIP and other profiles that require nonce_endpoint in metadata.
web.post(f"{subpath}/nonce", get_nonce),
web.get(f"{subpath}/nonce", get_nonce),
web.get(f"{subpath}/oid4vp/request/{{request_id}}", get_request),
web.post(f"{subpath}/oid4vp/response/{{presentation_id}}", post_response),
]
# Conditionally register the /nonce endpoint
if config.enable_nonce_endpoint:
routes.append(web.post(f"{subpath}/nonce", get_nonce))
# Conditionally add status route
if context.inject_or(StatusHandler):
routes.append(
Expand Down
Loading
Loading