EUDI钱包与OpenID4VP及pac4j:同设备与跨设备认证
欧洲数字身份钱包即将到来,接受它的应用将需要与之通信。本教程通过两个完整示例,将钱包认证引入使用 pac4j 的 Java Spring Boot 应用:第一个示例中浏览器和钱包在同一部手机上,第二个示例中计算机上的浏览器与手机上的钱包通过二维码连接。
pac4j 是一个开源 Java 安全库,用于跨多种 Web 框架进行认证和授权。我是 Jérôme LELEU,pac4j 的创建者和维护者。本文结合了最初发布在 pac4j 博客上的两篇操作指南:同一设备上的流程和另一设备上的流程。
我们先介绍共用背景和准备工作,然后构建每个示例,最后讨论局限性与后续步骤。每个示例都有自己的 Maven 项目和完整配置;请一次运行一个,端口为 8080。
1)背景与共用准备
1.1)为什么要为 EUDI 钱包做准备?
欧盟条例 (EU) 2024/1183,更广为人知的名称是 eIDAS 2.0,已于 2024 年 5 月 20 日生效。欧盟成员国必须在 2026 年底前提供 EUDI 钱包,其中包含个人身份识别数据(PID)以及驾驶执照、文凭等证明。使用钱包仍然是自愿的。
第 5f 条还要求,需要电子身份识别的公共服务、需要强认证的某些私营服务,以及需要认证的超大型在线平台,必须接受钱包。私营部门的义务附带条件和豁免,包括针对微型企业和小型企业的规定,其截止期限与实施法案挂钩,通常被描述为 2027 年末。
对于范围内的应用而言,钱包认证正在成为一项值得提前准备的集成要求。
1.2)为什么很难
钱包登录看起来很简单:用户打开钱包,看到你的应用请求的内容,表示同意,然后钱包发送一份签名证明。但在背后,这套技术栈一点也不简单。你的应用——在规范术语中称为验证方(verifier)——必须处理:
- OpenID for Verifiable Presentations 1.0,即协议本身:钱包如何被调用、如何获取请求、如何提交响应,以及用其 Digital Credentials Query Language(数字凭证查询语言,DCQL)来表达你想要什么
- 高保证互操作性配置文件(HAIP),它固定了 EUDI 钱包使用的选项;还有欧盟委员会的架构与参考框架(ARF)及其 PID 规则手册
- 两种凭证格式:IETF 的 SD-JWT VC,基于选择性披露 JWT;以及 ISO/IEC 18013-5 的移动文档(mdoc),采用 CBOR 编码并使用 COSE 签名
- 底层基础设施:签名的请求对象(RFC 9101)、JWS、JWE 和 JWK、X.509 证书链与吊销、去中心化标识符,以及用于最新流程的浏览器 Digital Credentials API。
此外,还有多种识别验证方的方式、多种触达钱包的方式、多种响应模式,每种都有各自的验证规则。手工实现这些,并在规范演进时始终保持正确,本身就是一项工程。
1.3)pac4j 提供支持
pac4j 为每种协议提供统一的安全模型:Client 启动认证,回调接收应答,最后 UserProfile 进入会话。CAS、SAML、OpenID Connect、OAuth 都适配这一模型,数字钱包也是如此。
pac4j v6.6.0 引入的新模块 pac4j-openid4vp 增加了 EudiWalletClient:你向它提供 DCQL 查询、密钥库和信任的签发者,它就会处理签名请求、加密响应、凭证验证和用户资料。如果你已经用过 pac4j,这种集成方式会看起来很熟悉。
我们将逐一介绍两个流程:首先是同一设备上的流程,用户在手机上点击链接并出示 SD-JWT VC PID;然后是另一设备上的流程,二维码将计算机浏览器与手机钱包连接起来,由手机钱包出示 mdoc PID。凭证格式和设备组合是彼此独立的选择:这些示例将它们配对,以演示两种格式。
1.4)一个年轻的协议:用演示钱包测试
OpenID4VP 1.0 于 2025 年定稿,钱包实现仍在演进。演示钱包让你能够在为国家 EUDI 部署做准备的同时测试集成;不同钱包和版本之间很可能存在差异。
一个非常好的选择是 Animo 推出的 Paradym 钱包,它支持 Android 和 iOS,其 playground 同时扮演签发者和验证方:
- 在手机上安装 Paradym 钱包
- 在计算机上打开 playground.paradym.id,在 Issuance 页面分别签发 PID SD-JWT(用于第 2 部分)和 PID mDoc(用于第 3 部分):每张卡都会在页面上显示一个二维码;用 Paradym 钱包扫描它,接受提议,卡片就会出现在钱包中
- 将 playground 的 Trust Information 页面放在手边:它显示了 playground 签发者的证书,你的应用必须信任该证书。
这些卡片属于 Emma Charlotte van der Berg,一位虚构的荷兰公民:名字、姓氏、出生日期、地址……mdoc PID 还带有一个 personal_administrative_number,供第 3 部分的资料解析器使用。
请只从这个 playground 签发卡片,不要从其他来源签发:在别处签发的 PID,例如由较旧的 playground.animo.id 签发,会由另一张证书签名,1.6 节中的信任库会拒绝它。如果钱包中已经持有来自其他来源的 PID 卡片,请删除它们,或者至少在钱包询问要出示哪张卡片时选对卡片。
1.5)ngrok:为钱包提供真实 URL
钱包从手机通过 HTTPS 与你的应用通信:它获取请求对象并提交响应。尽管浏览器和钱包运行在同一部手机上,应用却运行在你的开发计算机上。手机上的 localhost 指向手机自身,因此无法访问该应用。
开发期间最简单的方法是使用 ngrok,它将你的本地端口暴露在一个公共 HTTPS 地址上:
它会打印一个公共 URL,例如 https://abcd-1234.ngrok-free.dev:在本文余下部分,这就是你的应用的基础 URL。免费计划包含一个分配给你账户的开发域名。将 ngrok 打印的 URL 复制到下面的属性中。如果浏览器显示 ngrok 警告页面,选择 Visit Site 继续。
1.6)密钥与信任
两个示例使用相同的密钥与信任设置:metadata 目录中有两个密钥库。在 2.1 或 3.1 节创建项目后,在该项目根目录(与 pom.xml 同级)运行下面的 keytool 命令:命令中的路径是相对于该目录的,与配置中的路径一样。如果你要尝试两个项目,请在每个项目目录中重复此设置,或者将两个演示密钥库复制到第二个项目的 metadata 目录中。
验证方密钥。 EudiWalletClient 会对请求签名,并通过证书哈希(x509_hash)标识验证方。生产环境的 EUDI 部署使用由生态系统签发的依赖方访问证书。两个演示都使用带 EC P-256 密钥的自签名证书,测试过的 Paradym 钱包接受该证书;自签名验证方证书不在 HAIP 要求范围内:
你信任的签发者。 除非签发者受信任,否则任何凭证都不会被接受,而 pac4j 默认不信任任何人。对于 mdoc,信任锚是 PID 提供方的签发机构证书颁发机构(IACA)。将 playground 的 Trust Information 页面中的 Issuer Certificate 复制到 paradym-issuer.pem 文件中,并将其导入信任库:
2)同一设备
浏览器和钱包运行在同一部手机上。我们请求一个 SD-JWT VC PID,并让钱包在共享后返回原浏览器。
2.1)创建 Spring Boot 项目
这是一个普通的 Spring Boot Web 应用,使用 spring-webmvc-pac4j 作为 pac4j 实现。Java 17 或更高版本以及 Maven 就足够了。
pom.xml:
SD-JWT VC 验证依赖 EUDI SD-JWT 库,pac4j 将其声明为可选依赖。本示例使用与 pac4j 匹配的版本 0.20.2。导入的 pac4j-parent 依赖管理会对齐 pac4j-core 和共享库。为 pac4j-jakartaee 显式添加的管理条目还会对齐由 spring-webmvc-pac4j 引入的适配器;仅设置 pac4j.version 属性无法覆盖该适配器的传递版本。
运行时版本与 pac4j 的构建保持一致:Kotlin 2.4.21、Coroutines 1.11.0 和 Serialization 1.12.0。Serialization 1.12.0 可从 Maven Central 获取;同一设备演示不需要 walt.id 仓库。Spring Boot 4.0.8 提供 Spring Framework 7.0.9,与 pac4j 匹配;相关属性还对 Jackson 2、Commons Lang、SLF4J、Commons Logging 和 JSpecify 进行了对齐。Jackson 3 仍由 Boot 为其自身 Web 技术栈管理。
否则,Boot 默认的 Kotlin 依赖会覆盖 pac4j 的版本。特别是,Coroutines 1.10.2 缺少当前的 pac4j 适配器所调用的 BuildersKt.runBlockingK$default,会在钱包提交响应时导致 NoSuchMethodError。
使用上面声明的已发布 pac4j 6.6.0 依赖;无需安装本地快照。用 mvn dependency:tree 检查解析后的版本,并让所有 pac4j 模块保持同一版本。
src/main/resources/application.properties 文件保存基础 URL,并启用我们将在最后查看的日志:
以及常见的 Spring Boot 入口点:
在配置 pac4j 之前,请按 1.6 节所述,在此项目的 metadata 目录中准备好两个密钥库。### 2.2) 配置 pac4j
配置由 spring-webmvc-pac4j 中的 Pac4jSecurityConfig 完成:Config bean 定义客户端,拦截器指定要保护哪些 URL。
几个配置细节:
EudiWalletClient固定了 HAIP 的选择:请求会被签名,并通过x509_hash(即密钥库中证书的哈希)标识验证方,响应则以加密形式返回(direct_post.jwt)。这些设置由客户端应用;它们并不构成完整的 HAIP 一致性验证。SdJwtVcVerifier验证预期格式。使用此信任配置时,钱包的x5c证书链必须指向你信任库中的某个证书,并且还要通过签名、有效期、披露(disclosure)和持有者绑定检查。- profile 标识符由你选择:
EudiWalletClient没有默认值,在你配置解析器之前会拒绝初始化。如果 PID 提供方签发稳定、唯一且不会被重新分配的personal_administrative_number,可在 DCQL 中请求该字段,并使用ProfileIdResolver.issuerAndClaim(PERSONAL_ADMINISTRATIVE_NUMBER)。并非每个 PID 都保证包含该声明,本演示也没有请求它,因此使用随机标识符:它标识的是一次登录,而不是持久用户账户。 - 回调 URL 是 pac4j 的常规回调 URL,钱包也使用它:钱包在该 URL 获取请求对象,并在该 URL 提交响应,全程不需要浏览器会话。
setUseRedirectUri(true)启用自动返回浏览器。接受钱包响应后,pac4j 会将其回调 URL 作为redirect_uri返回,并附带一个新的response_code。钱包在浏览器中打开该 URL;pac4j 会根据浏览器会话的事务校验该代码,保存 profile,并重定向到最初请求的受保护页面。该选项默认为false;这个同设备示例显式启用了它。
playground PID 带有一个 status 声明,它引用 OAuth Token Status List:这是签发方标记其已吊销或暂停凭证的列表。除非配置了状态检查器,否则 pac4j 会拒绝带有状态的凭证,因为只有应用才能决定如何读取该列表,而 pac4j 自身从不获取它。本演示的检查器只记录该引用并接受凭证,因此这里不会检查吊销状态。真正的检查器会检索状态列表令牌,使用与凭证相同的信任配置验证其签名,并检查凭证索引处的条目:这值得单独写一篇文章。参见凭证状态。禁用证书吊销检查不会绕过此检查。
2.3) DCQL 查询
查询表达你的应用想要什么。EudiPidQuery.sdJwtVc(GIVEN_NAME, FAMILY_NAME) 为 PID 用一行代码构建查询;钱包收到的就是以下内容:
一个凭证查询,标识为 pid,用于类型为 EUDI PID 的 SD-JWT VC,并请求披露两个声明。同样可以手写;如果来自配置文件,则用 JSON:
或者在 Java 中编写,并可访问预期值、备选项(claim_sets、credential_sets)和受信任的机构:
钱包出示的任何内容都会由 pac4j 再次对照查询进行检查:格式、类型、声明。约束不会留给钱包来决定。
2.4) 页面
两个页面就够了:一个公开首页,以及一个显示 profile 的受保护页面。两者都来自同一个 Spring MVC 控制器 WalletController,需创建在前两个类旁边:
首页只需要启动认证的链接。回调由 pac4j 处理:使用 setUseRedirectUri(true) 时,钱包会自动将浏览器返回到该处,然后 pac4j 恢复原始请求。不需要第二个链接、轮询脚本或转发 response_code 的应用代码。
运行应用,同时在旁边运行 ngrok:
2.5) 流程详解
所有操作都在手机上完成。首先打开 Paradym 钱包并解锁,然后将其留在后台:如果浏览器把 openid4vp:// 链接交给钱包时钱包尚未运行,流程就会失败。接着**在手机的默认浏览器中,使用其常规用户配置(而非隐私浏览)**打开 ngrok URL,并点击首页上的链接:Open the protected page: the wallet opens。
钱包可能会在手机的默认浏览器中打开返回 URL。因此,如果从其他浏览器、隐私浏览会话或嵌入式浏览器开始,可能会丢失原始会话:回调无法完成认证,演示会返回首页。整个流程中请使用同一个浏览器和用户配置。
下面的六张屏幕截图展示了登录开始的首页、待审阅的请求、关于验证方未经验证的警告、PIN 码、钱包成功界面,以及返回浏览器中的受保护页面并显示 profile 的过程。以下步骤会跟踪这次交互直到最终页面。
[LOADING...] 1. 应用首页:打开受保护页面以启动钱包登录 [LOADING...] 2. Paradym:审阅请求 [LOADING...] 3. Paradym:组织未经验证 [LOADING...] 4. Paradym:输入 PIN 码 [LOADING...] 5. Paradym:成功,信息已共享 [LOADING...] 6. 回到应用:钱包自动返回后,受保护页面显示 Emma Charlotte van der Berg 的 profile
1. 浏览器请求受保护页面。 pac4j 找不到 profile,创建一个事务,并重定向到 openid4vp:// URL。浏览器将此协议方案交给操作系统,由操作系统打开钱包。
2. 钱包获取请求。 URL 只携带验证方标识符和 request_uri:钱包会在你的应用回调 URL 上调用它,并收到签名的请求对象,其中包含 DCQL 查询和用于加密其响应的公钥。
3. 用户同意。 钱包显示谁在请求、目前显示的是你证书的哈希,以及请求的内容:PID、名字和姓氏。用户选择卡片并输入 PIN 码确认。
4. 钱包提交响应。 一个加密 JWT 被提交到回调 URL,其中包含带有两项披露的 SD-JWT VC,以及一个证明钱包持有凭证密钥的密钥绑定 JWT。pac4j 解密、验证并将结果保留给启动该事务的浏览器会话。其 HTTP 200 JSON 响应包含一个指向 /callback/EudiWalletClient?response_code=... 的 redirect_uri。这是浏览器返回 URL,而不是钱包刚刚提交出示内容的 response_uri。
5. 浏览器自动返回。 钱包在默认浏览器中打开 redirect_uri。由于流程是在该浏览器和用户配置中启动的,其会话 cookie 会随回调一起发送。pac4j 会用该会话的待处理事务校验 response_code,取回已在第 4 步验证过的 profile,消费该事务,并将 profile 存入会话。随后重定向到第 1 步保存的 URL /protected/index:受保护页面显示 Emma Charlotte van der Berg。该代码完全由 pac4j 处理,不会带到最终的受保护 URL。
response_code 是 OpenID4VP 第 8.2 节 要求的新的随机值。回调同时需要此代码和启动该事务的会话;缺失或错误的代码会被拒绝,其他会话无法认领该出示内容,已完成的事务也不能再次被消费。请确保返回到原始浏览器:不同的浏览器或隔离的 WebView 没有其会话 cookie。第 3 部分改用轮询,因为钱包在手机上,而等待答案的浏览器在电脑上。### 2.6) 日志说明了什么
application.properties 中启用的两个日志记录器从两个视角呈现该流程。PROTOCOL_MESSAGE.OPENID4VP 日志记录器会按消息发送或接收时的原样打印消息,不做任何解释:每条消息一行,应用发送时以 >>> 开头,接收时以 <<< 开头,后面是另一方,即浏览器或钱包。org.pac4j.openid4vp 日志记录器则说明 pac4j 对这些消息执行了什么操作:创建事务、构建请求对象、解密并验证响应、构建用户资料。下面的摘录经过删减;最终的 JSON 响应和浏览器返回展示了 setUseRedirectUri(true) 配置。
重定向第 1 步:创建一个事务,五分钟后过期,浏览器被发送到钱包 URL。注意 request_uri,它位于回调 URL 上,路径中包含客户端名称;还要注意验证方的标识符,即其证书的哈希值:
前两行只在第一次请求时出现一次:客户端采用延迟初始化。该配置为每种格式提供一个默认验证方;2.2 节用受信任签发者替换了 SD-JWT VC 的验证方,而 mdoc 的验证方保持不变,因此列出了两种格式。
第 2 步:钱包获取请求对象;该对象是为其构建并签名的:
这些演示轨迹显示的是 GET,尽管钱包 URL 声明了 request_uri_method=post。pac4j 接受这两种方法;遵循所声明方法的钱包会使用 POST,并可包含其元数据和钱包 nonce。
请求对象是一个 JWT,其头部携带你的证书(x5c,其中 rp 别名作为 kid),其负载就是该请求。解码后,其负载如下:
第 4 步:钱包提交其响应,即使用请求中发布的临时密钥加密的 JWE。pac4j 将其解密,验证 SD-JWT VC、其证书链、其披露内容和密钥绑定,将状态引用交给 2.2 节的检查器,并构建用户资料:
该用户资料获得的内容不止请求的两个声明:签发者还在任何披露之外签名了 vct、签发国家与签发机构、有效期、持有者密钥(cnf)以及状态引用,钱包无法隐瞒这些内容。两个姓名是唯一被披露的声明。
第 5 步:钱包在浏览器中打开返回的回调 URL。pac4j 使用浏览器会话中保存的事务标识符检查 response_code;返回 URL 不携带 vp_tx。然后它取用已验证的用户资料,而不会再次验证,随后常规回调重定向到保存的 /protected/index URL:
注意,这些日志会显示凭据在传输过程中的内容,包括令牌和返回的 response_code:启用它们是为了诊断,不要在生产环境中长期启用。
3) 在另一台设备上
现在浏览器运行在计算机上,而钱包仍留在手机上。第二个项目请求 mdoc PID,显示二维码,并轮询钱包响应。在 8080 端口启动此应用之前,请先停止第一个应用。
3.1) 创建 Spring Boot 项目
这是一个普通的 Spring Boot Web 应用,使用 spring-webmvc-pac4j 作为 pac4j 实现。Java 17 或更高版本以及 Maven 即可。
pom.xml。mdoc 验证依赖 walt.id 库;pac4j 将其声明为可选依赖,而 walt.id 在自己的仓库中发布该库:
walt.id 库由 Kotlin 编写。此 POM 与 pac4j 的运行时版本保持一致:Kotlin 2.4.21、Coroutines 1.11.0、Serialization 1.12.0 和 Datetime 0.8.0-0.6.x-compat。Serialization 1.12.0 可从 Maven Central 获取;mdoc 库仍然需要 walt.id 仓库。Datetime 兼容构件保留了 walt.id 0.12.0 使用的旧版 API。不要将其替换为移除该 API 的版本。参见 Kotlin 对齐,尤其是在同时验证 SD-JWT VC 和 mdoc 凭据时。
导入的 pac4j-parent 依赖管理会对齐 pac4j-core 及其共享库;显式声明 pac4j-jakartaee 条目,使 spring-webmvc-pac4j 引入的适配器与 pac4j-openid4vp 对齐。Spring Boot 4.0.8 提供 Spring Framework 7.0.9,与 pac4j 匹配;相关属性还会对齐 Jackson 2、Commons Lang、SLF4J、Commons Logging 和 JSpecify。Jackson 3 仍由 Boot 为其自身的 Web 技术栈管理。如果让 Boot 选择其较旧的 Kotlin 依赖,即使演示程序能够编译,也可能导致运行时链接失败。
使用上面声明的已发布 pac4j 6.6.0 依赖;无需安装本地快照。可使用 mvn dependency:tree 检查解析后的版本,并让所有 pac4j 模块保持同一版本。
src/main/resources/application.properties 文件保存基础 URL,并启用我们将在最后读取的日志:
以及通常的 Spring Boot 入口点:
在配置 pac4j 之前,请按 1.6 节所述,在此项目的 metadata 目录中准备好两个密钥库。### 3.2) 配置 pac4j
该配置是一个来自 spring-webmvc-pac4j 的 Pac4jSecurityConfig:Config bean 定义客户端,而拦截器说明它保护哪些 URL。
与第 2 部分相比,有三处变化:
useRedirectUri保持为false,即其默认值。在第 2 部分中它为true,因此钱包会让手机的浏览器返回 pac4j 回调。而在这里,已认证会话属于计算机:重定向手机并不会把其会话 cookie 带过来。计算机页面轮询响应,并自行继续到回调,不需要response_code。- 验证器是
MdocVerifier,它检查文档的签发者认证、摘要、有效性、设备签名、响应与请求的绑定,以及文档签名者一直到你的信任库的证书链。 - mdoc 的声明按命名空间分组:对于 PID,命名空间就是其文档类型
eu.europa.ec.eudi.pid.1。profile 标识符会在该命名空间中读取行政编号,而 profile 会暴露一个以该命名空间命名的属性,其中包含这三个声明。ProfileIdResolver.issuerAndClaim(...)只读取 SD-JWT VC 的顶层声明,因此才有了上面的小型解析器。
其余部分相同:EudiWalletClient 固定采用 HAIP 选项 x509_hash 和 direct_post.jwt,并且回调 URL 同时服务于钱包和浏览器。
这些设置并不构成完整的 HAIP 一致性验证。mdoc 可能在其 MSO 中携带一个 status 对象,它是对 OAuth Token Status List 的引用:该列表由签发者标记其已撤销或已暂停的文档。除非配置了状态检查器,否则 pac4j 会拒绝携带状态的文档,因为只有应用才能决定如何读取该列表,而 pac4j 绝不会自行获取它。playground 的 mdoc PID 目前不携带任何状态,这一点与其 SD-JWT VC PID 不同,但检查器仍保持配置,以便携带状态的文档被记录而不是被拒绝:它接收状态对象作为 map,仅记录并接受该文档,因此这里并不检查撤销状态。真正的检查器会检索状态列表 token,以与文档相同的信任验证其签名,并检查文档索引处的条目:这值得单独写一篇文章。参见 凭据状态。禁用证书吊销检查并不会绕过此检查。
3.3) DCQL 查询
该查询说明你的应用需要什么。EudiPidQuery.mdoc(GIVEN_NAME, FAMILY_NAME, PERSONAL_ADMINISTRATIVE_NUMBER) 为 PID 用一行构建它;钱包收到的就是以下内容:
一个凭据查询,标识为 pid,用于类型为 EUDI PID 的移动文档;还有三个数据元素,每个都通过其命名空间和名称来寻址。同样的内容也可以手写;当它来自配置文件时,用 JSON 表示:
或者用 Java 编写,从而可以访问预期值、备选方案(claim_sets、credential_sets)和受信任的权威机构:
无论钱包出示什么,pac4j 都会再次对照该查询进行检查:格式、文档类型、命名空间和元素。约束不会交由钱包处理。
3.4) 页面与二维码
在此流程中,钱包在手机上,而不是在运行浏览器的计算机上。页面将钱包 URL 作为数据获取,并将其显示为二维码,然后等待钱包的响应。以下三部分实现了这一点:
- 受保护页面本身,与之前一样
- 一个
/status端点,它报告浏览器会话的事务状态:来自client.getPresentationStatus(...)的PENDING、RECEIVED或EXPIRED。RECEIVED包括钱包错误响应;随后回调会报告该错误 - 包含二维码及其脚本的页面,它每秒轮询
/status端点,直到钱包作出回答。
这三部分位于单个 Spring MVC 控制器 WalletController 中,应创建在前两个类旁边:
在 start() 中,pac4j 识别 X-Requested-With 标头,并返回 401,同时将钱包 URL 放在 Location 标头中,而不是进行重定向。页面将该 URL 渲染为二维码。如果会话已经认证,则直接进入受保护页面;HTTP 或网络故障会出现在页面的日志中。
然后,poll() 在完成的状态请求之间等待一秒,直到收到 received 或 expired。收到 received 时,它将浏览器发送到回调。这将轮询限制为每页每秒最多一个请求,继续之前约有 1 秒加请求时间的延迟。状态会使用浏览器会话中的标识符从事务存储中读取;默认存储位于内存中,不需要发出网络调用。qrcode-generator 库在浏览器中渲染二维码,而不会将其内容发送给二维码服务。
WebContext 和 SessionStore bean 来自 spring-webmvc-pac4j,作用域为当前请求;getPresentationStatus 会查找该会话的事务,并报告钱包是否已提交其回答。
运行应用程序,并让 ngrok 与其一起运行:
EudiWalletClient : applying EUDI wallet settings for client EudiWalletClient: X509_HASH, DIRECT_POST_JWT, PID profile definition OpenId4VpConfiguration : OpenID4VP configuration checked: 1 credential queries, verifier formats=[SD_JWT_VC, MSO_MDOC], signed requests=true VpTransaction : transaction c7e29a…: - -> CREATED (client EudiWalletClient, response mode direct_post.jwt, expires at 2026-10-12T14:26:08Z) PROTOCOL_MESSAGE.OPENID4VP : [tx c7e29a…] >>> browser wallet URL: openid4vp://?client_id=x509_hash%3AE3bEQjnx15zqNwvnVfbxz2GO6SAZXNG6BfdjrZsWvQc&request_uri=https%3A%2F%2Fabcd-1234.ngrok-free.dev%2Fcallback%3Fclient_name%3DEudiWalletClient%26vp_tx%3Dc7e29a…&request_uri_method=post EudiWalletClient : AJAX request detected -> returning UnauthorizedAction(super=HttpAction(code=401), content=) for https://abcd-1234.ngrok-free.dev/protected/index
PROTOCOL_MESSAGE.OPENID4VP : [tx c7e29a…] <<< wallet GET client_name=EudiWalletClient&vp_tx=c7e29a… OpenId4VpRequestObjectBuilder : client metadata built for transaction c7e29a…: formats=[dc+sd-jwt, mso_mdoc], response encryption methods=[A128GCM, A256GCM] OpenId4VpRequestObjectBuilder : request object signed for transaction c7e29a… with ES256 VpTransaction : transaction c7e29a…: CREATED -> REQUEST_RETRIEVED (GET of the request URI) PROTOCOL_MESSAGE.OPENID4VP : [tx c7e29a…] >>> wallet 200 eyJ4NWMiOlsiTUlJQmNq…
{ "client_id": "x509_hash:E3bEQjnx15zqNwvnVfbxz2GO6SAZXNG6BfdjrZsWvQc", "response_type": "vp_token", "response_mode": "direct_post.jwt", "response_uri": "https://abcd-1234.ngrok-free.dev/callback?client_name=EudiWalletClient&vp_tx=c7e29a…", "nonce": "fb89b405c7ca40689912b460acd0e756", "state": "206a039fe14d4d1f9a3fa0c50f8ff207…", "dcql_query": { "credentials": [ { "id": "pid", "format": "mso_mdoc", "meta": { "doctype_value": "eu.europa.ec.eudi.pid.1" }, "claims": [ { "path": ["eu.europa.ec.eudi.pid.1", "given_name"] }, { "path": ["eu.europa.ec.eudi.pid.1", "family_name"] }, { "path": ["eu.europa.ec.eudi.pid.1", "personal_administrative_number"] } ] } ] }, "client_metadata": { "jwks": { "keys": [ { "kty": "EC", "use": "enc", "crv": "P-256", "alg": "ECDH-ES", "kid": "3885759a…", "x": "…", "y": "…" } ] }, "encrypted_response_enc_values_supported": ["A128GCM", "A256GCM"], "vp_formats_supported": { "dc+sd-jwt": { "sd-jwt_alg_values": ["ES256"], "kb-jwt_alg_values": ["ES256"] }, "mso_mdoc": { "issuerauth_alg_values": [-7], "deviceauth_alg_values": [-7] } } }, "aud": "https://self-issued.me/v2", "iat": 1791814868, "exp": 1791815168 }
PROTOCOL_MESSAGE.OPENID4VP : [tx c7e29a…] <<< wallet POST client_name=EudiWalletClient&vp_tx=c7e29a…&response=eyJraWQiOiIzODg1NzU5YS… OpenId4VpAuthenticator : response decrypted and authentication tag verified for transaction c7e29a…: alg=ECDH-ES, enc=A128GCM, members=[vp_token, state] CertificateTrustedIssuer : issuer truststore loaded: 1 trust anchors CertificateTrustedIssuer : certificate revocation lists loaded: 0 MdocVerifier : mdoc verified for transaction c7e29a… OpenId4VpAuthenticator : presentation 1/1 verified for transaction c7e29a…, query pid: format=MSO_MDOC, verifier=MdocVerifier OpenId4VpAuthenticator : wallet response validated for transaction c7e29a…: 1 credential queries answered EudiPidProfile : adding => key: eu.europa.ec.eudi.pid.1 / value: {given_name=Emma Charlotte, family_name=van der Berg, personal_administrative_number=ADM2023080012345} / class java.util.LinkedHashMap OpenId4VpAuthenticator : profile built for transaction c7e29a…: profile class=EudiPidProfile, definition=EudiPidProfileDefinition VpTransaction : transaction c7e29a…: REQUEST_RETRIEVED -> RESPONSE_RECEIVED (presentation validated) PROTOCOL_MESSAGE.OPENID4VP : [tx c7e29a…] >>> wallet 200 {}
VpTransaction : transaction c7e29a…: RESPONSE_RECEIVED -> CONSUMED (the browser came back) OpenId4VpAuthenticator : transaction c7e29a…: reusing the profile validated when the wallet posted its response, nothing is verified again EudiWalletClient : credentials: VerifiablePresentationCredentials(… verifiedCredentials={pid=[VerifiedCredential(format=MSO_MDOC, type=eu.europa.ec.eudi.pid.1, issuer=C=NL,CN=Paradym Playground, cryptographicHolderBinding=true, trustedAuthorities={aki=[IyNQbCkBG38NdbXBZT0C3rElbng]}, claims={eu.europa.ec.eudi.pid.1={given_name=Emma Charlotte, family_name=van der Berg, personal_administrative_number=ADM2023080012345}})]}) EudiWalletClient : profile: Optional[EudiPidProfile(id=C=NL,CN=Paradym Playground|ADM2023080012345, attributes={eu.europa.ec.eudi.pid.1={given_name=Emma Charlotte, family_name=van der Berg, personal_administrative_number=ADM2023080012345}}, …)]