OpenID for Verifiable Credential Issuance
Contents
OpenID for Verifiable Credential Issuance#
This guide is for developers implementing credential issuance using the OpenID4VCI protocol in OpenSPP or integrating with OpenSPP as a credential issuer.
Overview#
OpenID for Verifiable Credential Issuance (OpenID4VCI) is a protocol that enables wallets to request and receive verifiable credentials from issuers using OAuth 2.0 flows. OpenSPP implements OpenID4VCI 1.0.
Why OpenID4VCI?#
Traditional credential issuance requires custom protocols and tight integration. OpenID4VCI provides:
Standardized Flow
Wallet-agnostic protocol
OAuth 2.0 security model
Well-known discovery endpoints
Multiple Grant Types
Pre-authorized code (push model)
Authorization code (pull model)
Combined flows
Metadata Discovery
Issuers advertise capabilities
Wallets discover supported credentials
Automatic configuration
Protocol Flow#
Pre-Authorized Code Flow#
This is the most common flow in OpenSPP. The issuer generates a credential offer that the wallet can redeem without user interaction.
sequenceDiagram
participant R as Registry/OpenSPP
participant B as Beneficiary
participant W as Wallet
R->>B: 1. Credential Offer (QR/Link)
Note over R,B: Contains pre-authorized code
B->>W: 2. Scan/Open Offer
W->>R: 3. POST /token
Note over W,R: Exchange code for access token
R-->>W: Access Token
W->>R: 4. POST /credential
Note over W,R: Present access token
R-->>W: Verifiable Credential (SD-JWT)
W->>W: 5. Store in Wallet
Step-by-Step#
1. Generate Credential Offer
# In OpenSPP
entitlement = env['spp.entitlement'].browse(entitlement_id)
credential_type = env['spp.credential.type'].search([
('code', '=', 'EntitlementCredential')
], limit=1)
# Create credential offer
offer_service = env['spp.credential.offer']
offer = offer_service.create_offer(
credential_subject=entitlement,
credential_type=credential_type,
holder_wallet_did='did:web:wallet.example:holder123'
)
# Generate QR code or deep link
credential_offer_uri = offer.get_credential_offer_uri()
# Returns: openid-credential-offer://?credential_offer=...
2. Credential Offer Format
{
"credential_issuer": "https://registry.example",
"credential_configuration_ids": ["EntitlementCredential"],
"grants": {
"urn:ietf:params:oauth:grant-type:pre-authorized_code": {
"pre-authorized_code": "eyJhbGciOiJFUzI1NiJ9...",
"tx_code": {
"input_mode": "numeric",
"length": 6,
"description": "Enter the PIN sent to your mobile"
}
}
}
}
3. Token Exchange
# Wallet makes request
curl -X POST https://registry.example/api/v1/openid4vci/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:pre-authorized_code" \
-d "pre-authorized_code=eyJhbGciOiJFUzI1NiJ9..." \
-d "tx_code=123456"
Response:
{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 86400,
"c_nonce": "random-challenge-for-key-binding",
"c_nonce_expires_in": 86400
}
4. Credential Request
curl -X POST https://registry.example/api/v1/openid4vci/credential \
-H "Authorization: Bearer eyJhbGciOiJFUzI1NiJ9..." \
-H "Content-Type: application/json" \
-d '{
"format": "vc+sd-jwt",
"credential_definition": {
"type": ["VerifiableCredential", "EntitlementCredential"]
},
"proof": {
"proof_type": "jwt",
"jwt": "eyJhbGciOiJFUzI1NiJ9..."
}
}'
Response:
{
"format": "vc+sd-jwt",
"credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6InZjK3NkLWp3dCJ9.eyJpc3MiOi...~WyJzYWx0MSIsICJnaXZlbk5hbWUiLCAiSmFuZSJd~..."
}
Authorization Code Flow#
For user-initiated credential requests (wallet pulls from issuer).
sequenceDiagram
participant W as Wallet
participant R as Registry/OpenSPP
participant B as Beneficiary
W->>R: 1. Authorization Request
Note over W,R: With scope, redirect_uri
R->>B: 2. Authentication & Consent
B-->>R: Approve
R-->>W: 3. Authorization Code
Note over R,W: Via redirect
W->>R: 4. POST /token
Note over W,R: Exchange code
R-->>W: Access Token + c_nonce
W->>R: 5. POST /credential
Note over W,R: With proof of possession
R-->>W: Verifiable Credential
Authorization Request:
https://registry.example/api/v1/openid4vci/authorize
?response_type=code
&client_id=wallet.example
&redirect_uri=https://wallet.example/callback
&scope=EntitlementCredential
&state=random-state-value
Issuer Metadata#
OpenSPP publishes issuer metadata at the well-known endpoint.
Discovery Endpoint#
GET /.well-known/openid-credential-issuer
Response:
{
"credential_issuer": "https://registry.example",
"credential_endpoint": "https://registry.example/api/v1/openid4vci/credential",
"batch_credential_endpoint": "https://registry.example/api/v1/openid4vci/batch_credential",
"deferred_credential_endpoint": "https://registry.example/api/v1/openid4vci/deferred_credential",
"display": [
{
"name": "OpenSPP Social Registry",
"locale": "en-US",
"logo": {
"uri": "https://registry.example/logo.png",
"alt_text": "OpenSPP Logo"
}
}
],
"credential_configurations_supported": {
"EntitlementCredential": {
"format": "vc+sd-jwt",
"scope": "EntitlementCredential",
"cryptographic_binding_methods_supported": ["jwk"],
"cryptographic_suites_supported": ["ES256"],
"display": [
{
"name": "Entitlement Credential",
"locale": "en-US",
"logo": {
"uri": "https://registry.example/credentials/entitlement.png"
},
"background_color": "#12107c",
"text_color": "#FFFFFF"
}
],
"credential_definition": {
"type": ["VerifiableCredential", "EntitlementCredential"],
"credentialSubject": {
"entitlementId": {
"mandatory": true,
"display": [{"name": "Entitlement ID", "locale": "en-US"}]
},
"amount": {
"mandatory": false,
"display": [{"name": "Amount", "locale": "en-US"}]
}
}
}
}
}
}
Configuring Metadata#
Metadata is generated from credential type configurations:
# spp_verifiable_credentials/models/credential_type.py
class CredentialType(models.Model):
_name = "spp.credential.type"
# Display metadata
display_name = fields.Char()
display_locale = fields.Char(default='en-US')
display_logo_uri = fields.Char()
background_color = fields.Char(default='#12107c')
text_color = fields.Char(default='#FFFFFF')
def get_credential_configuration(self):
"""Generate OpenID4VCI credential configuration"""
return {
'format': 'vc+sd-jwt',
'scope': self.code,
'cryptographic_binding_methods_supported': ['jwk'],
'cryptographic_suites_supported': ['ES256'],
'display': [{
'name': self.display_name or self.name,
'locale': self.display_locale,
'logo': {'uri': self.display_logo_uri} if self.display_logo_uri else None,
'background_color': self.background_color,
'text_color': self.text_color,
}],
'credential_definition': self._get_credential_definition(),
}
Implementation in OpenSPP#
Module Structure#
spp_openid_vci/ # Base module
├── models/
│ ├── credential_offer.py # Offer generation
│ └── openid_issuer.py # Issuer configuration
└── data/
└── issuer_config.xml
spp_openid_vci_rest_api/ # REST endpoints
├── routers/
│ └── openid_vci.py # FastAPI router
└── controllers/
└── main.py # Odoo controller
REST API Endpoints#
Implemented using FastAPI in spp_openid_vci_rest_api:
# routers/openid_vci.py
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/v1/openid4vci")
class TokenRequest(BaseModel):
grant_type: str
pre_authorized_code: str | None = None
tx_code: str | None = None
@router.post("/token")
def token_endpoint(request: TokenRequest, env=Depends(get_odoo_env)):
"""Exchange authorization code or pre-authorized code for access token"""
if request.grant_type == "urn:ietf:params:oauth:grant-type:pre-authorized_code":
# Validate pre-authorized code
offer = env['spp.credential.offer'].validate_pre_authorized_code(
request.pre_authorized_code,
request.tx_code
)
# Generate access token
token_service = env['spp.token.service']
access_token = token_service.create_token(
offer_id=offer.id,
scope=offer.credential_type_id.code
)
return {
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 86400,
"c_nonce": token_service.generate_nonce(),
"c_nonce_expires_in": 86400,
}
raise HTTPException(status_code=400, detail="Unsupported grant type")
class CredentialRequest(BaseModel):
format: str
credential_definition: dict
proof: dict | None = None
@router.post("/credential")
def credential_endpoint(
request: CredentialRequest,
authorization: str = Header(...),
env=Depends(get_odoo_env)
):
"""Issue credential in exchange for access token"""
# Validate bearer token
token = authorization.replace("Bearer ", "")
token_service = env['spp.token.service']
token_data = token_service.validate_token(token)
# Get credential offer
offer = env['spp.credential.offer'].browse(token_data['offer_id'])
# Verify proof of possession (KB-JWT)
if request.proof:
env['spp.sd.jwt'].verify_key_binding_jwt(
request.proof['jwt'],
token_data['c_nonce']
)
# Issue credential
credential_subject = offer.get_credential_subject()
vc = credential_subject.issue_credential(
offer.credential_type_id.id,
holder_did=offer.holder_wallet_did
)
return {
"format": "vc+sd-jwt",
"credential": vc
}
Credential Offer Service#
# spp_openid_vci/models/credential_offer.py
class CredentialOffer(models.Model):
_name = "spp.credential.offer"
credential_type_id = fields.Many2one('spp.credential.type', required=True)
credential_subject_model = fields.Char()
credential_subject_id = fields.Integer()
holder_wallet_did = fields.Char()
pre_authorized_code = fields.Char()
tx_code = fields.Char() # Optional PIN
state = fields.Selection([
('pending', 'Pending'),
('redeemed', 'Redeemed'),
('expired', 'Expired'),
], default='pending')
def create_offer(self, credential_subject, credential_type, holder_wallet_did=None):
"""Create credential offer for a subject"""
# Generate pre-authorized code (JWT)
jwt_service = self.env['spp.jwt.service']
pre_auth_code = jwt_service.encode({
'offer_id': self.id,
'exp': datetime.now() + timedelta(days=7),
})
self.write({
'credential_subject_model': credential_subject._name,
'credential_subject_id': credential_subject.id,
'credential_type_id': credential_type.id,
'holder_wallet_did': holder_wallet_did,
'pre_authorized_code': pre_auth_code,
})
return self
def get_credential_offer_uri(self):
"""Generate credential offer URI for QR code"""
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
offer_json = {
'credential_issuer': base_url,
'credential_configuration_ids': [self.credential_type_id.code],
'grants': {
'urn:ietf:params:oauth:grant-type:pre-authorized_code': {
'pre-authorized_code': self.pre_authorized_code,
}
}
}
if self.tx_code:
offer_json['grants']['urn:ietf:params:oauth:grant-type:pre-authorized_code']['tx_code'] = {
'input_mode': 'numeric',
'length': len(self.tx_code),
}
import json
import urllib.parse
encoded = urllib.parse.quote(json.dumps(offer_json))
return f"openid-credential-offer://?credential_offer={encoded}"
def get_credential_subject(self):
"""Get the record this offer is for"""
return self.env[self.credential_subject_model].browse(self.credential_subject_id)
Security Considerations#
Token Security#
Short-lived Access Tokens
Default: 24 hours
Single-use for credential requests
Bound to specific credential offer
Nonce Management
Fresh nonce (
c_nonce) with each tokenRequired in proof of possession
Prevents replay attacks
Proof of Possession#
Wallets must prove they control the key pair:
{
"proof": {
"proof_type": "jwt",
"jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImtiK2p3dCJ9.eyJub25jZSI6ImMtbm9uY2UtdmFsdWUiLCJhdWQiOiJodHRwczovL3JlZ2lzdHJ5LmV4YW1wbGUiLCJpYXQiOjE3MDQwNjcyMDB9..."
}
}
KB-JWT payload:
{
"nonce": "c-nonce-value-from-token-response",
"aud": "https://registry.example",
"iat": 1704067200
}
PIN Protection (TX Code)#
For high-value credentials, add PIN requirement:
offer = offer_service.create_offer(
credential_subject=entitlement,
credential_type=credential_type,
holder_wallet_did='did:web:wallet.example:holder123',
tx_code='123456' # 6-digit PIN
)
# Send PIN to beneficiary via SMS
sms_service.send_pin(entitlement.partner_id.phone, '123456')
Testing#
Test Credential Issuance Flow#
# tests/test_openid4vci.py
from odoo.tests import TransactionCase
import json
class TestOpenID4VCI(TransactionCase):
def setUp(self):
super().setUp()
self.credential_type = self.env.ref('spp_verifiable_credentials.credential_type_entitlement')
self.entitlement = self.env['spp.entitlement'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
'initial_amount': 5000.0,
'state': 'approved',
})
def test_pre_authorized_code_flow(self):
"""Test complete pre-authorized code flow"""
# 1. Create offer
offer_service = self.env['spp.credential.offer']
offer = offer_service.create({
'credential_type_id': self.credential_type.id,
})
offer = offer.create_offer(
self.entitlement,
self.credential_type,
holder_wallet_did='did:web:wallet.test:holder1'
)
pre_auth_code = offer.pre_authorized_code
self.assertTrue(pre_auth_code)
# 2. Exchange for access token
token_service = self.env['spp.token.service']
token_response = token_service.exchange_pre_authorized_code(
pre_auth_code,
tx_code=None
)
self.assertIn('access_token', token_response)
self.assertIn('c_nonce', token_response)
# 3. Request credential
from fastapi.testclient import TestClient
client = TestClient(app) # FastAPI app
response = client.post(
'/api/v1/openid4vci/credential',
headers={
'Authorization': f"Bearer {token_response['access_token']}"
},
json={
'format': 'vc+sd-jwt',
'credential_definition': {
'type': ['VerifiableCredential', 'EntitlementCredential']
}
}
)
self.assertEqual(response.status_code, 200)
credential = response.json()['credential']
self.assertTrue(credential.startswith('eyJ')) # JWT format
def test_issuer_metadata(self):
"""Test well-known metadata endpoint"""
from fastapi.testclient import TestClient
client = TestClient(app)
response = client.get('/.well-known/openid-credential-issuer')
self.assertEqual(response.status_code, 200)
metadata = response.json()
self.assertIn('credential_issuer', metadata)
self.assertIn('credential_configurations_supported', metadata)
self.assertIn('EntitlementCredential', metadata['credential_configurations_supported'])
Are You Stuck?#
Getting "invalid_grant" error during token exchange?
Check:
Pre-authorized code hasn't expired (default: 7 days)
Code hasn't been redeemed already (single-use)
TX code (PIN) matches if required
Debug:
offer = env['spp.credential.offer'].search([
('pre_authorized_code', '=', 'your-code-here')
])
print(f"State: {offer.state}")
print(f"Expires: {offer.expiration_date}")
Proof of possession verification failing?
Common issues:
Wrong
c_nonce- must match value from token responseExpired nonce (default: 24 hours)
Wrong audience - must be issuer's base URL
Clock skew - ensure wallet time is synchronized
Verify KB-JWT:
sd_jwt_service = env['spp.sd.jwt']
result = sd_jwt_service.verify_key_binding_jwt(
kb_jwt='eyJ...',
expected_nonce='c-nonce-value',
expected_audience='https://registry.example'
)
print(result)
Metadata endpoint returning 404?
Ensure:
spp_openid_vci_rest_apimodule is installedFastAPI app is mounted in Odoo
Base URL configured:
Settings → System Parameters → web.base.url
Test:
curl https://registry.example/.well-known/openid-credential-issuer
How do I customize credential display in wallets?
Update credential type display metadata:
credential_type = env['spp.credential.type'].browse(type_id)
credential_type.write({
'display_name': 'My Custom Credential',
'display_logo_uri': 'https://registry.example/logo.png',
'background_color': '#FF5733',
'text_color': '#FFFFFF',
})
Wallets will fetch metadata and render credentials accordingly.
See Also#
Verifiable Credentials Overview - Verifiable Credentials Overview
W3C Verifiable Credentials Data Model - W3C VC Data Model
Verifiable Credentials Implementation Guide - Implementation Guide
openspp.org