[java-idp-plugin-vci] 01/01: Also jwt-vc-issuer and authorization-server are published now using velocity templates

Codeberg noreply at shibboleth.net
Wed Sep 9 13:15:41 UTC 2026


This is an automated email from the git hooks/post-receive script.

codeberg pushed a commit to branch dev/Publish
in repository java-idp-plugin-vci.

View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/5fa4296a1dd1e6781de3a1330626971350a6683d

commit 5fa4296a1dd1e6781de3a1330626971350a6683d
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Wed Sep 9 16:15:20 2026 +0300

    Also jwt-vc-issuer and authorization-server are published now using velocity templates
---
 README.md                                          | 203 ++++++++++++-----
 .../openidvci/metadata/MetadataDocument.java       | 124 ++++++++++
 .../resolver/MetadataDocumentResolver.java         |  31 +++
 .../impl/MetadataDocumentSuccessResponse.java      |  65 ++++++
 ... => AbstractTemplatedFileMetadataResolver.java} |  58 +++--
 ...FilesystemCredentialIssuerMetadataResolver.java | 251 +--------------------
 .../impl/FilesystemMetadataDocumentResolver.java   |  63 ++++++
 .../impl/DefaultMetadataDocumentConfiguration.java |  52 +++++
 ...ormOutboundMetadataDocumentResponseMessage.java | 120 ++++++++++
 .../META-INF/net.shibboleth.idp/postconfig.xml     |  38 ++++
 .../authorization-server-beans.xml                 |  40 ++++
 .../authorization-server-flow.xml                  |  22 ++
 .../oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml    |  40 ++++
 .../oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml     |  22 ++
 .../idp/service/relying-party/postconfig.xml       |  14 ++
 .../plugin/openidvci/conf/openid-vci.properties    |  28 +++
 .../shibboleth/plugin/openidvci/module.properties  |  11 +-
 .../plugin/openidvci/static/jwt-vc-issuer.json     |   4 +-
 .../static/oauth-authorization-server.json         |  10 +-
 19 files changed, 859 insertions(+), 337 deletions(-)

diff --git a/README.md b/README.md
index 7f2dd05..bc9d1a7 100644
--- a/README.md
+++ b/README.md
@@ -124,8 +124,8 @@ Installing the plugin activates module `OpenID-VCI` and creates six files:
 | *conf/openid-vci-credentials.xml* | Which key signs credentials, Status List Tokens and issuer metadata. Inactive by default. |
 | *conf/openid-vci-oidfed.xml* | States this deployment as a Credential Issuer in OpenID Federation. Not imported by default. |
 | *static/openid-credential-issuer.json* | Credential Issuer metadata, as a Velocity template. |
-| *static/jwt-vc-issuer.json* | Keys that sign the credentials, for a verifier. You publish it yourself. |
-| *static/oauth-authorization-server.json* | Authorization server metadata of the VCI endpoints, for a wallet. You publish it yourself. |
+| *static/jwt-vc-issuer.json* | Keys that sign the credentials, for a verifier. As a Velocity template. |
+| *static/oauth-authorization-server.json* | Authorization server metadata of the VCI endpoints, for a wallet. As a Velocity template. |
 
 One file you need to write yourself, *metadata/verifiable-credentials.json*, the Credential
 Configurations. There is no template for it. See [Credential Configurations](#credential-configurations).
@@ -507,6 +507,8 @@ for the wallet that redeems the code:
             <list>
                 <!-- The wallet reads the Credential Issuer metadata -->
                 <ref bean="OpenID.VCI.IssuerMetadata" />
+                <!-- The wallet finds the token endpoint through this one -->
+                <ref bean="OAuth.AuthorizationServer" />
                 <!-- The wallet asks for the nonce its key proof has to carry -->
                 <ref bean="OpenID.VCI.Nonce" />
                 <!-- A verifier reads the Status List -->
@@ -710,6 +712,8 @@ code flow:
             <list>
                 <!-- The wallet reads the Credential Issuer metadata -->
                 <ref bean="OpenID.VCI.IssuerMetadata" />
+                <!-- The wallet finds the token endpoint through this one -->
+                <ref bean="OAuth.AuthorizationServer" />
                 <!-- The wallet asks for the nonce its key proof has to carry -->
                 <ref bean="OpenID.VCI.Nonce" />
                 <!-- A verifier reads the Status List -->
@@ -1026,13 +1030,16 @@ enabled by default.
 | `/idp/profile/openid/vci/token` | **OpenID.VCI.Token** | `http://geant.org/ns/profiles/openid/vci/token` |
 | `/idp/profile/openid/vci/credentials` | **OpenID.VCI.Credentials** | `http://geant.org/ns/profiles/openid/vci/credentials` |
 | `/idp/profile/openid/vci/nonce` | **OpenID.VCI.Nonce** | `http://geant.org/ns/profiles/openid/vci/nonce` |
+| `/idp/profile/oauth/jwt-vc-issuer` | **OAuth.JWTVCIssuer** | `http://geant.org/ns/profiles/oauth/jwt-vc-issuer` |
+| `/idp/profile/oauth/authorization-server` | **OAuth.AuthorizationServer** | `http://geant.org/ns/profiles/oauth/authorization-server` |
 | `/idp/profile/oauth/status-list/list` | **OAuth.StatusList** | `http://geant.org/ns/profiles/oauth/status-list` |
 | `/idp/profile/oauth/status-list/revoke` | **OAuth.StatusList.Revoke** | `http://geant.org/ns/profiles/oauth/status-list/revoke` |
 | `/idp/profile/oauth/status-list/assignments` | **OAuth.StatusList.Assignments** | `http://geant.org/ns/profiles/oauth/status-list/assignments` |
 
 Some of these endpoints are reached without any client registration, so they belong to
-**shibboleth.UnverifiedRelyingParty**. **OpenID.VCI.IssuerMetadata** and **OpenID.VCI.Nonce**
-serve wallets and **OAuth.StatusList** serves verifiers. **OpenID.VCI.Token** and
+**shibboleth.UnverifiedRelyingParty**. **OpenID.VCI.IssuerMetadata**, **OpenID.VCI.Nonce**,
+**OAuth.JWTVCIssuer** and **OAuth.AuthorizationServer** serve wallets and **OAuth.StatusList**
+serves verifiers. **OpenID.VCI.Token** and
 **OpenID.VCI.Credentials** belong there too if you accept unregistered wallets.
 
 <details>
@@ -1044,6 +1051,10 @@ serve wallets and **OAuth.StatusList** serves verifiers. **OpenID.VCI.Token** an
         <list>
             <!-- Anyone may read the Credential Issuer metadata -->
             <ref bean="OpenID.VCI.IssuerMetadata" />
+            <!-- Anyone may read the keys that signed a credential -->
+            <ref bean="OAuth.JWTVCIssuer" />
+            <!-- Anyone may read the authorization server metadata -->
+            <ref bean="OAuth.AuthorizationServer" />
             <!-- Anyone may ask for a nonce -->
             <ref bean="OpenID.VCI.Nonce" />
             <!-- Anyone may read a Status List -->
@@ -1137,11 +1148,11 @@ works for as long as the OP has an EC key. Signing does ES256, ES384 and ES512 o
 |---|---|
 | `openidvci.issuer` | The `iss` of a credential is the plain issuer url instead of the `did:jwk` of the signing key. Empty is the default and gives `did:jwk`, which carries the key inside the credential and needs nothing published. |
 
-#### What the Credential Issuer metadata says
+#### What the discovery documents say
 
-The document is a Velocity template of yours and the `credential_configurations_supported`
-member is filled in from the file of credentials. See
-[Discovery documents](#discovery-documents).
+Three documents are published, each from a Velocity template of yours in *static/*. One member
+is filled in rather than written by hand, the `credential_configurations_supported` of the
+first. See [Discovery documents](#discovery-documents).
 
 | Switch | Why you would touch it |
 |---|---|
@@ -1152,6 +1163,7 @@ member is filled in from the file of credentials. See
 | **openidvci.CredentialIssuerMetadata** | Replace the document wholesale with a resource of your own. |
 | `openidvci.issuerMetadata.signedLifetime` | Signed metadata carries an `exp`. Unset publishes it without one. |
 | `openidvci.issuerMetadata.minRefreshDelay`, `…maxRefreshDelay` | How soon an edit of the document takes effect. |
+| `openidvci.jwtVcIssuer.*`, `openidvci.authorizationServer.*` | The same settings for the other two documents, each with its own template, resolver and refresh bounds. |
 
 #### What a pre-authorized code looks like
 
@@ -1206,11 +1218,26 @@ side storage rather than the memory backed default of a cluster.
 
 ### Discovery documents
 
-A wallet finds the issuer at `/.well-known/openid-credential-issuer`. The document comes from
-the issuer-metadata endpoint, which reads *static/openid-credential-issuer.json*. The file is a
-Velocity template and `$baseUrl`, `$issuer` and `$host` in it are resolved from the issuer of
-the OP. `credential_configurations_supported` is not in the file at all, it is taken from
-*metadata/verifiable-credentials.json* when the document is published:
+A wallet reads three documents and every one of them comes from an endpoint of this plugin.
+Each endpoint reads a file of *static/*, and each of those files is a Velocity template where
+`$baseUrl`, `$issuer` and `$host` are resolved from the issuer of the OP. None of them names
+your host, so none of them needs editing to name it.
+
+| Document | Endpoint | File |
+|---|---|---|
+| `/.well-known/openid-credential-issuer` | `/idp/profile/openid/vci/issuer-metadata` | *static/openid-credential-issuer.json* |
+| `/.well-known/jwt-vc-issuer` | `/idp/profile/oauth/jwt-vc-issuer` | *static/jwt-vc-issuer.json* |
+| `/.well-known/oauth-authorization-server` | `/idp/profile/oauth/authorization-server` | *static/oauth-authorization-server.json* |
+
+One member is not in the files at all. `credential_configurations_supported` of the first is
+taken from *metadata/verifiable-credentials.json*, so that what you advertise is what you are
+able to issue. Everything else in all three documents is yours to write.
+
+The Credential Issuer metadata is published only when it carries `credential_endpoint` and
+`credential_configurations_supported`. Without either one the endpoint answers `500
+server_error` and says which member is missing in the log, rather than publish a document a
+wallet cannot use. The second of them is missing when *metadata/verifiable-credentials.json*
+cannot be read, so a broken file of credentials takes the metadata endpoint down with it.
 
 <details>
 <summary>Example contents of static/openid-credential-issuer.json</summary>
@@ -1244,45 +1271,36 @@ the OP. `credential_configurations_supported` is not in the file at all, it is t
 
 </details>
 
-Two more documents that a wallet reads this plugin does not serve at all. Both of them come
-with the plugin as static files and you publish them yourself. They name no host, so you need
-to replace `{{ service_name }}` in them with yours.
-
 `/.well-known/jwt-vc-issuer` tells a verifier which keys sign your credentials. Its `issuer`
-must be the same value the credentials carry as `iss`:
+has to be the same value the credentials carry as `iss`. The document matters only when your
+Credential Issuer identifier is the plain issuer url. With `did:jwk`, which is the default, the
+key travels inside the credential and nobody reads the document.
 
 <details>
 <summary>Contents of static/jwt-vc-issuer.json as it comes with the plugin</summary>
 
 ```json
 {
-   "issuer":"https://{{ service_name }}",
-   "jwks_uri":"https://{{ service_name }}/idp/profile/oidc/keyset"
+   "issuer":"$issuer",
+   "jwks_uri":"$baseUrl/idp/profile/oidc/keyset"
 }
 ```
 
 </details>
 
-The document matters when your Credential Issuer identifier is the plain issuer URL. With
-`did:jwk`, which is the default, the key travels inside the credential and nobody needs the
-document.
-
 `/.well-known/oauth-authorization-server` tells a wallet where the token endpoint is. Note that
-`token_endpoint` is the VCI one and not the OP's own. `scopes_supported` ships with `openid`
-only and you add to it every credential configuration id you want to be requestable with the
-code flow, `GeantIncubatorDiploma_SDJWT` and `GeantIncubatorDiploma_W3C` of the walkthrough above for
-instance:
+`token_endpoint` is the VCI one and not the OP's own.
 
 <details>
 <summary>Contents of static/oauth-authorization-server.json as it comes with the plugin</summary>
 
 ```json
 {
-   "issuer":"https://{{ service_name }}",
-   "authorization_endpoint":"https://{{ service_name }}/idp/profile/oidc/authorize",
-   "pushed_authorization_request_endpoint":"https://{{ service_name }}/idp/profile/oauth2/pushed-authorization",
-   "token_endpoint":"https://{{ service_name }}/idp/profile/openid/vci/token",
-   "jwks_uri":"https://{{ service_name }}/idp/profile/oidc/keyset",
+   "issuer":"$issuer",
+   "authorization_endpoint":"$baseUrl/idp/profile/oidc/authorize",
+   "pushed_authorization_request_endpoint":"$baseUrl/idp/profile/oauth2/pushed-authorization",
+   "token_endpoint":"$baseUrl/idp/profile/openid/vci/token",
+   "jwks_uri":"$baseUrl/idp/profile/oidc/keyset",
    "token_endpoint_auth_methods_supported":[
       "none",
       "client_secret_basic",
@@ -1292,9 +1310,6 @@ instance:
    "response_types_supported":[
       "code"
    ],
-   "scopes_supported":[
-      "openid"
-   ],
    "authorization_details_types_supported":[
       "openid_credential"
    ]
@@ -1303,9 +1318,9 @@ instance:
 
 </details>
 
-Then the paths. None of the three `/.well-known/` locations is an IdP path, so you wire them in
-Jetty. Wallets that run in a browser need also `Access-Control-Allow-Origin` in the responses,
-so that header belongs to the same file. With Jetty rewrite handler:
+Then the paths. No `/.well-known/` location is an IdP path, so you wire each one to its
+endpoint in Jetty. Wallets that run in a browser need also `Access-Control-Allow-Origin` in the
+responses, so that header belongs to the same file. With Jetty rewrite handler:
 
 <details>
 <summary>Example contents of etc/jetty-rewrite-rules.xml</summary>
@@ -1332,12 +1347,12 @@ so that header belongs to the same file. With Jetty rewrite handler:
       </New>
     </Arg>
   </Call>
-  <!-- These two are the static files you wrote -->
+  <!-- These two come from this plugin as well -->
   <Call name="addRule">
     <Arg>
       <New class="org.eclipse.jetty.rewrite.handler.RewritePatternRule">
         <Set name="pattern">/.well-known/jwt-vc-issuer</Set>
-        <Set name="replacement">/.well-known/jwt-vc-issuer.json</Set>
+        <Set name="replacement">/idp/profile/oauth/jwt-vc-issuer</Set>
       </New>
     </Arg>
   </Call>
@@ -1345,11 +1360,11 @@ so that header belongs to the same file. With Jetty rewrite handler:
     <Arg>
       <New class="org.eclipse.jetty.rewrite.handler.RewritePatternRule">
         <Set name="pattern">/.well-known/oauth-authorization-server</Set>
-        <Set name="replacement">/.well-known/oauth-authorization-server.json</Set>
+        <Set name="replacement">/idp/profile/oauth/authorization-server</Set>
       </New>
     </Arg>
   </Call>
-  <!-- The JSON-LD context of the vc+sd-jwt credential -->
+  <!-- The JSON-LD context of the vc+sd-jwt credential is a static file of Jetty -->
   <Call name="addRule">
     <Arg>
       <New class="org.eclipse.jetty.rewrite.handler.RewritePatternRule">
@@ -1373,29 +1388,24 @@ so that header belongs to the same file. With Jetty rewrite handler:
 
 </details>
 
-You copy the two documents from *static/* to the root webapp of Jetty, to
-*webapps/root/.well-known/*, and the JSON-LD context of a `vc+sd-jwt` credential goes to
-*webapps/root/credentials/*. The header rule above sets the header for every path of the
+The JSON-LD context of a `vc+sd-jwt` credential is the one document that stays a file of Jetty,
+in *webapps/root/credentials/*. The header rule above sets the header for every path of the
 deployment, which is what our test deployment does. You may want to limit it to the metadata
 paths only.
 
-You need to do all of this only because the work is in progress. The plugin should publish
-every document a wallet reads and it should publish them in the paths the specifications name.
-Until it does, the paths are wired by hand and the two documents are yours to maintain, so
-expect this chapter to shrink.
-
 #### Notes
 
 The OP's own `/.well-known/openid-configuration` does not describe the VCI endpoints. It
 advertises the OP token endpoint instead of `/idp/profile/openid/vci/token` and it does not
-list your credential scopes. A wallet that reads only that document fails. Publish
-`/.well-known/oauth-authorization-server` with the VCI token endpoint in it.
+list your credential scopes. A wallet that reads only that document fails, so wire
+`/.well-known/oauth-authorization-server` to **OAuth.AuthorizationServer** as above.
 
-Each profile of this plugin is described below. Six of the eight inherit the settings of the
+Each profile of this plugin is described below. Six of the ten inherit the settings of the
 OAuth2/OIDC profile configurations of the OP plugin, `tokenEndpointAuthMethods`,
 `accessTokenLifetime`, `forcePKCE`, `securityConfiguration` and the DPoP settings among them.
-**OpenID.VCI.IssuerMetadata** and **OAuth.StatusList** are the exceptions. They serve requests
-that have no client at all and inherit nothing of that. Every setting of this plugin is in
+**OpenID.VCI.IssuerMetadata**, **OAuth.JWTVCIssuer**, **OAuth.AuthorizationServer** and
+**OAuth.StatusList** are the exceptions. They serve requests that have no client at all and
+inherit nothing of that. Every setting of this plugin is in
 [Profile configuration settings](#profile-configuration-settings), the ones a profile really
 reads are named with the profile.
 
@@ -1425,6 +1435,67 @@ document is *static/openid-credential-issuer.json* as described above.
 |---|---|---|---|
 | `issuerMetadataSignatureSigningConfiguration` | SignatureSigningConfiguration | Bean named **openidvci.issuerMetadata.SigningConfiguration** | Signing configuration of the metadata. The document is published unsigned until **openidvci.issuerMetadata.SigningCredentials** exists. |
 
+### OAuth.JWTVCIssuer
+
+Publishes the JWT VC issuer metadata. A `GET` of `/idp/profile/oauth/jwt-vc-issuer`, no
+authentication and no request parameters. The document is *static/jwt-vc-issuer.json* as
+described above.
+
+<details>
+<summary>Example snippet of conf/relying-party.xml</summary>
+
+```xml
+<bean id="shibboleth.UnverifiedRelyingParty" parent="RelyingParty">
+    <property name="profileConfigurations">
+        <list>
+            ...
+            <ref bean="OAuth.JWTVCIssuer" />
+        </list>
+    </property>
+</bean>
+```
+
+</details>
+
+You need this endpoint only when you set `openidvci.issuer` to the plain issuer url. A verifier
+reads the document to find the key that signed a credential, and it looks for the document only
+when the credential names its issuer by url. With the default `did:jwk` identifier the key
+travels inside the credential, nothing fetches this document and enabling it changes nothing.
+
+There is nothing to configure per relying party. The document is the same for everyone.
+
+### OAuth.AuthorizationServer
+
+Publishes the authorization server metadata. A `GET` of
+`/idp/profile/oauth/authorization-server`, no authentication and no request parameters. The
+document is *static/oauth-authorization-server.json* as described above.
+
+<details>
+<summary>Example snippet of conf/relying-party.xml</summary>
+
+```xml
+<bean id="shibboleth.UnverifiedRelyingParty" parent="RelyingParty">
+    <property name="profileConfigurations">
+        <list>
+            ...
+            <ref bean="OAuth.AuthorizationServer" />
+        </list>
+    </property>
+</bean>
+```
+
+</details>
+
+You need this endpoint in both flows. A wallet finds the token endpoint through it, and the OP's
+own `/.well-known/openid-configuration` does not name the VCI one. Enable it unless you know
+that every wallet you serve is told the token endpoint some other way.
+
+Everything in the document is yours to write. A wallet naming a credential by scope rather than
+by configuration id needs that scope in `scopes_supported`, so add the ones you want
+requestable with the code flow.
+
+There is nothing to configure per relying party.
+
 ### OpenID.VCI.CredentialOffer
 
 Creates a Credential Offer. A `POST` of `application/json` to
@@ -1720,6 +1791,10 @@ examples of this document. `OIDC.Keyset`, `OIDC.Configuration`, `OAUTH2.TokenAud
                 <ref bean="OAUTH2.TokenAudience" />
                 <!-- Anyone may read the Credential Issuer metadata -->
                 <ref bean="OpenID.VCI.IssuerMetadata" />
+                <!-- Anyone may read the keys that signed a credential -->
+                <ref bean="OAuth.JWTVCIssuer" />
+                <!-- Anyone may read the authorization server metadata -->
+                <ref bean="OAuth.AuthorizationServer" />
                 <!-- Anyone may ask for a nonce -->
                 <ref bean="OpenID.VCI.Nonce" />
                 <!-- Anyone may read a Status List -->
@@ -1814,6 +1889,16 @@ plugin and is loaded automatically, you do not need to touch `idp.additionalProp
 | `openidvci.issuerMetadata.minRefreshDelay` | Duration | `PT5M` | Lower bound on re-reading the metadata file. |
 | `openidvci.issuerMetadata.maxRefreshDelay` | Duration | `PT4H` | Upper bound on re-reading the metadata file. |
 | `openidvci.issuerMetadata.signedLifetime` | Duration | | `exp` of signed metadata. Unset publishes it without one. |
+| `openidvci.jwtVcIssuer.template` | File pathname | `%{idp.home}/static/jwt-vc-issuer.json` | The JWT VC issuer metadata document. |
+| `openidvci.jwtVcIssuer.templated` | Boolean | `true` | Whether to evaluate that document as Velocity. |
+| `openidvci.jwtVcIssuer.resolver` | Bean ID | Bean named **openidvci.DefaultJWTVCIssuerMetadataResolver** | Resolver of that document. |
+| `openidvci.jwtVcIssuer.minRefreshDelay` | Duration | `PT5M` | Lower bound on re-reading that file. |
+| `openidvci.jwtVcIssuer.maxRefreshDelay` | Duration | `PT4H` | Upper bound on re-reading that file. |
+| `openidvci.authorizationServer.template` | File pathname | `%{idp.home}/static/oauth-authorization-server.json` | The authorization server metadata document. |
+| `openidvci.authorizationServer.templated` | Boolean | `true` | Whether to evaluate that document as Velocity. |
+| `openidvci.authorizationServer.resolver` | Bean ID | Bean named **openidvci.DefaultAuthorizationServerMetadataResolver** | Resolver of that document. |
+| `openidvci.authorizationServer.minRefreshDelay` | Duration | `PT5M` | Lower bound on re-reading that file. |
+| `openidvci.authorizationServer.maxRefreshDelay` | Duration | `PT4H` | Upper bound on re-reading that file. |
 | `openidvci.signing.es.key` | File pathname | `%{idp.home}/credentials/openid-vci-signing-es.jwk` | Signing key of issued credentials. Used only if enabled in *conf/openid-vci-credentials.xml*. Must be EC. |
 | `openidvci.signing.status-list.es.key` | File pathname | `%{idp.home}/credentials/openid-vci-status-list-signing-es.jwk` | Signing key of Status List Tokens. Must be EC. |
 | `openidvci.signing.issuerMetadata.es.key` | File pathname | `%{idp.home}/credentials/openid-vci-issuer-metadata-signing-es.jwk` | Signing key of Credential Issuer metadata. Must be EC. |
@@ -1830,6 +1915,8 @@ plugin and is loaded automatically, you do not need to touch `idp.additionalProp
 | `openidvci.logging.token` | String | `OpenID.VCI.Token` | Logging id of the token flow. |
 | `openidvci.logging.nonce` | String | `OpenID.VCI.Nonce` | Logging id of the nonce flow. |
 | `openidvci.logging.issuer-metadata` | String | `OpenID.VCI.IssuerMetadata` | Logging id of the issuer-metadata flow. |
+| `openidvci.logging.jwt-vc-issuer` | String | `OAuth.JWTVCIssuer` | Logging id of the jwt-vc-issuer flow. |
+| `openidvci.logging.authorization-server` | String | `OAuth.AuthorizationServer` | Logging id of the authorization-server flow. |
 | `openidvci.logging.status-list.list` | String | `OAuth.StatusList` | Logging id of the status list flow. |
 | `openidvci.logging.status-list.revoke` | String | `OAuth.StatusList.Revoke` | Logging id of the revoke flow. |
 | `openidvci.logging.status-list.assignments` | String | `OAuth.StatusList.Assignments` | Logging id of the assignments flow. |
@@ -1889,7 +1976,11 @@ definitions in *conf/global.xml* or in any other location that is imported.
 | **openidvci.TokenManipulationStrategy** | Function | Carries authorization details and claim values into the authorization code and access token. Reference it from **OIDC.SSO** and **OpenID.VCI.Token**. |
 | **openidvci.CredentialConfigurationsResolver** | CredentialConfigurationsResolver | Reads *metadata/verifiable-credentials.json*, shared by every flow. |
 | **openidvci.CredentialIssuerMetadata** | Resource | Define to replace the issuer metadata document wholesale. |
+| **openidvci.JWTVCIssuerMetadata** | Resource | Define to replace the JWT VC issuer metadata document wholesale. |
+| **openidvci.AuthorizationServerMetadata** | Resource | Define to replace the authorization server metadata document wholesale. |
 | **openidvci.issuerMetadata.TemplateContext** | Map | Define to add or replace the `$issuer`, `$baseUrl` and `$host` Velocity variables. |
+| **openidvci.jwtVcIssuer.TemplateContext** | Map | The same for the JWT VC issuer metadata document. |
+| **openidvci.authorizationServer.TemplateContext** | Map | The same for the authorization server metadata document. |
 | **openidvci.SigningCredentials** | List<Credential> | Define in *conf/openid-vci-credentials.xml* to sign credentials with your own key. Replaces the OP's credentials. |
 | **openidvci.status-list.SigningCredentials** | List<Credential> | Define to sign Status List Tokens with a key of their own. |
 | **openidvci.issuerMetadata.SigningCredentials** | List<Credential> | Define to enable signed issuer metadata. |
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
new file mode 100644
index 0000000..8e3ce4e
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/MetadataDocument.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.metadata;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+
+/**
+ * A metadata document this deployment publishes.
+ *
+ * Members are carried as they were parsed, this is the document that gets
+ * published. Only 'issuer' is parsed, it names the document. Everything else is
+ * the deployer's to write.
+ */
+public final class MetadataDocument {
+
+    /** Member naming the issuer. */
+    @Nonnull
+    public static final String ISSUER = "issuer";
+
+    /** Members of the document, in the order they were parsed. */
+    @Nonnull
+    private final Map<String, Object> members;
+
+    /** Issuer the document is for. */
+    @Nonnull
+    private final Issuer issuer;
+
+    /**
+     * Constructor.
+     *
+     * @param parsed     members of the document
+     * @param identifier issuer the document is for
+     */
+    private MetadataDocument(@Nonnull final Map<String, Object> parsed, @Nonnull final Issuer identifier) {
+        members = parsed;
+        issuer = identifier;
+    }
+
+    /**
+     * Get the issuer the document is for.
+     *
+     * @return the issuer
+     */
+    @Nonnull
+    public Issuer getIssuer() {
+        return issuer;
+    }
+
+    /**
+     * Get the members of the document.
+     *
+     * @return unmodifiable members, in the order they were parsed
+     */
+    @Nonnull
+    public Map<String, Object> getMembers() {
+        return members;
+    }
+
+    /**
+     * Get a modifiable copy of the members, to build another document out of this
+     * one.
+     *
+     * @return copy of the members, in the order they were parsed
+     */
+    @Nonnull
+    public Map<String, Object> toModifiableMembers() {
+        return new LinkedHashMap<>(members);
+    }
+
+    /**
+     * Get whether the document has a member.
+     *
+     * @param member member to look for
+     * @return whether the member is present
+     */
+    public boolean hasMember(@Nonnull final String member) {
+        return members.containsKey(member);
+    }
+
+    /**
+     * Parse a document.
+     *
+     * @param members members of the document
+     * @return the document
+     * @throws ParseException if the members do not name an issuer
+     */
+    @Nonnull
+    public static MetadataDocument parse(@Nullable final Map<String, Object> members) throws ParseException {
+
+        if (members == null) {
+            throw new ParseException("Metadata document cannot be null");
+        }
+
+        final Object issuer = members.get(ISSUER);
+        if (!(issuer instanceof String) || ((String) issuer).isEmpty()) {
+            throw new ParseException("Metadata document must have a non empty '" + ISSUER + "' member");
+        }
+        return new MetadataDocument(Collections.unmodifiableMap(new LinkedHashMap<>(members)),
+                new Issuer((String) issuer));
+    }
+
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/MetadataDocumentResolver.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/MetadataDocumentResolver.java
new file mode 100644
index 0000000..ec2f2c9
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/resolver/MetadataDocumentResolver.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.metadata.resolver;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.component.IdentifiedComponent;
+import net.shibboleth.shared.resolver.Resolver;
+
+/**
+ * Resolver of a {@link MetadataDocument} this deployment publishes.
+ */
+public interface MetadataDocumentResolver
+        extends Resolver<MetadataDocument, ProfileRequestContext>, IdentifiedComponent {
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java
new file mode 100644
index 0000000..24dab16
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/impl/MetadataDocumentSuccessResponse.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.messaging.impl;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.nimbusds.common.contenttype.ContentType;
+import com.nimbusds.oauth2.sdk.SuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+
+/**
+ * Response carrying a metadata document this deployment publishes.
+ */
+public class MetadataDocumentSuccessResponse implements SuccessResponse {
+
+    /** Serialized metadata document. */
+    @Nonnull
+    private final String content;
+
+    /**
+     * Constructor.
+     *
+     * @param document members of the metadata document
+     *
+     * @throws JsonProcessingException if the members cannot be serialized
+     */
+    public MetadataDocumentSuccessResponse(@Nonnull final Map<String, Object> document)
+            throws JsonProcessingException {
+        content = new ObjectMapper().writeValueAsString(document);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean indicatesSuccess() {
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public HTTPResponse toHTTPResponse() {
+        final HTTPResponse httpResponse = new HTTPResponse(HTTPResponse.SC_OK);
+        httpResponse.setEntityContentType(ContentType.APPLICATION_JSON);
+        httpResponse.setContent(content);
+        return httpResponse;
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
similarity index 79%
copy from openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
copy to openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
index a25cbf0..5f2a4eb 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/AbstractTemplatedFileMetadataResolver.java
@@ -33,8 +33,6 @@ import org.apache.velocity.VelocityContext;
 import org.apache.velocity.app.VelocityEngine;
 import org.apache.velocity.exception.VelocityException;
 import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
-import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
-import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
 import org.opensaml.profile.context.ProfileRequestContext;
 import org.slf4j.Logger;
 import org.springframework.core.io.Resource;
@@ -48,15 +46,23 @@ import net.shibboleth.oidc.metadata.impl.AbstractFileOIDCEntityResolver;
 import net.shibboleth.shared.collection.CollectionSupport;
 import net.shibboleth.shared.component.ComponentInitializationException;
 import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.Resolver;
 import net.shibboleth.shared.resolver.ResolverException;
 import net.shibboleth.shared.velocity.Template;
 
 /**
- * Credential Issuer metadata read from a file, with the refresh behaviour of
+ * A metadata document read from a file, with the refresh behaviour of
  * {@link AbstractFileOIDCEntityResolver}.
+ *
+ * The file is a Velocity template, so that the urls in it name this host
+ * without the file being edited. Variables are $issuer, $baseUrl and $host, all
+ * derived from the issuer given to this resolver, and a deployer adds to them
+ * or replaces them with a template context of their own.
+ *
+ * @param <T> type of the document being read
  */
-public class FilesystemCredentialIssuerMetadataResolver extends
-        AbstractFileOIDCEntityResolver<Issuer, CredentialIssuerMetadata> implements CredentialIssuerMetadataResolver {
+public abstract class AbstractTemplatedFileMetadataResolver<T>
+        extends AbstractFileOIDCEntityResolver<Issuer, T> implements Resolver<T, ProfileRequestContext> {
 
     /** Variable carrying the issuer as configured. */
     @Nonnull
@@ -76,7 +82,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
 
     /** Class logger. */
     @Nonnull
-    private final Logger log = LoggerFactory.getLogger(FilesystemCredentialIssuerMetadataResolver.class);
+    private final Logger log = LoggerFactory.getLogger(AbstractTemplatedFileMetadataResolver.class);
 
     /** Parser of the metadata file. */
     @Nonnull
@@ -105,7 +111,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
      *
      * @throws IOException if the metadata cannot be loaded
      */
-    public FilesystemCredentialIssuerMetadataResolver(@Nonnull final Resource metadata) throws IOException {
+    public AbstractTemplatedFileMetadataResolver(@Nonnull final Resource metadata) throws IOException {
         super(metadata);
     }
 
@@ -186,7 +192,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
         }
         variables = resolved;
 
-        log.debug("Credential Issuer metadata template variables {}", resolved);
+        log.debug("Metadata document template variables {}", resolved);
     }
 
     /**
@@ -202,7 +208,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
         assert velocityEngine != null;
 
         if (template.isBlank()) {
-            throw new ParseException("Credential Issuer metadata template is empty");
+            throw new ParseException("Metadata document template is empty");
         }
 
         final String document;
@@ -213,7 +219,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
             variables.forEach(context::put);
             document = compiled.merge(context);
         } catch (final VelocityException e) {
-            throw new ParseException("Unable to evaluate Credential Issuer metadata template: " + e.getMessage(), e);
+            throw new ParseException("Unable to evaluate the metadata document template: " + e.getMessage(), e);
         } finally {
             // fromTemplate registers the template in a repository that outlives it.
             if (compiled != null) {
@@ -223,7 +229,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
 
         if (UNRESOLVED.matcher(document).find()) {
             log.warn(
-                    "{} Credential Issuer metadata still reads as a template after evaluating it. A reference to "
+                    "{} Metadata document still reads as a template after evaluating it. A reference to "
                             + "something other than {} resolves to nothing and is left in place.",
                     getLogPrefix(), variables.keySet());
         }
@@ -234,7 +240,7 @@ public class FilesystemCredentialIssuerMetadataResolver extends
     /** {@inheritDoc} */
     @Override
     @Nonnull
-    public Iterable<CredentialIssuerMetadata> resolve(@Nullable final ProfileRequestContext profileRequestContext)
+    public Iterable<T> resolve(@Nullable final ProfileRequestContext profileRequestContext)
             throws ResolverException {
         ifNotInitializedThrowUninitializedComponentException();
         ifDestroyedThrowDestroyedComponentException();
@@ -245,29 +251,31 @@ public class FilesystemCredentialIssuerMetadataResolver extends
     /** {@inheritDoc} */
     @Override
     @Nullable
-    public CredentialIssuerMetadata resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
-            throws ResolverException {
-        final Iterator<CredentialIssuerMetadata> iterator = resolve(profileRequestContext).iterator();
+    public T resolveSingle(@Nullable final ProfileRequestContext profileRequestContext) throws ResolverException {
+        final Iterator<T> iterator = resolve(profileRequestContext).iterator();
         if (iterator.hasNext()) {
             return iterator.next();
         }
-        log.warn("{} No Credential Issuer metadata has been read in", getLogPrefix());
+        log.warn("{} No metadata document has been read in", getLogPrefix());
         return null;
     }
 
     /** {@inheritDoc} */
     @Override
     @Nonnull
-    protected List<CredentialIssuerMetadata> parse(@Nonnull final byte[] bytes) throws ParseException {
-        return CollectionSupport.listOf(CredentialIssuerMetadata.parse(readMembers(bytes)));
+    protected List<T> parse(@Nonnull final byte[] bytes) throws ParseException {
+        return CollectionSupport.listOf(fromMembers(readMembers(bytes)));
     }
 
-    /** {@inheritDoc} */
-    @Override
+    /**
+     * Build the document of the members read from the file.
+     *
+     * @param members members of the document
+     * @return the document
+     * @throws ParseException if the members do not make a document of this kind
+     */
     @Nonnull
-    protected Issuer getKey(@Nonnull final CredentialIssuerMetadata value) {
-        return value.getCredentialIssuer();
-    }
+    protected abstract T fromMembers(@Nonnull final Map<String, Object> members) throws ParseException;
 
     /**
      * Read the members of a metadata document.
@@ -289,11 +297,11 @@ public class FilesystemCredentialIssuerMetadataResolver extends
                     new TypeReference<LinkedHashMap<String, Object>>() {
                     });
             if (members == null) {
-                throw new ParseException("Credential Issuer metadata is not a JSON object");
+                throw new ParseException("Metadata document is not a JSON object");
             }
             return members;
         } catch (final IOException e) {
-            throw new ParseException("Unable to parse Credential Issuer metadata: " + e.getMessage(), e);
+            throw new ParseException("Unable to parse the metadata document: " + e.getMessage(), e);
         }
     }
 
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
index a25cbf0..a2f78dd 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemCredentialIssuerMetadataResolver.java
@@ -13,90 +13,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 package org.geant.shibboleth.plugin.openidvci.metadata.impl;
 
 import java.io.IOException;
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.nio.charset.StandardCharsets;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
 import java.util.Map;
-import java.util.regex.Pattern;
 
 import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
 
-import org.apache.velocity.VelocityContext;
-import org.apache.velocity.app.VelocityEngine;
-import org.apache.velocity.exception.VelocityException;
-import org.apache.velocity.runtime.resource.loader.StringResourceLoader;
 import org.geant.shibboleth.plugin.openidvci.metadata.CredentialIssuerMetadata;
 import org.geant.shibboleth.plugin.openidvci.metadata.resolver.CredentialIssuerMetadataResolver;
-import org.opensaml.profile.context.ProfileRequestContext;
-import org.slf4j.Logger;
 import org.springframework.core.io.Resource;
 
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import com.nimbusds.oauth2.sdk.ParseException;
 import com.nimbusds.oauth2.sdk.id.Issuer;
 
-import net.shibboleth.oidc.metadata.impl.AbstractFileOIDCEntityResolver;
-import net.shibboleth.shared.collection.CollectionSupport;
-import net.shibboleth.shared.component.ComponentInitializationException;
-import net.shibboleth.shared.primitive.LoggerFactory;
-import net.shibboleth.shared.resolver.ResolverException;
-import net.shibboleth.shared.velocity.Template;
-
 /**
- * Credential Issuer metadata read from a file, with the refresh behaviour of
- * {@link AbstractFileOIDCEntityResolver}.
+ * Credential Issuer metadata read from a file, the document named by its
+ * 'credential_issuer' member.
  */
-public class FilesystemCredentialIssuerMetadataResolver extends
-        AbstractFileOIDCEntityResolver<Issuer, CredentialIssuerMetadata> implements CredentialIssuerMetadataResolver {
-
-    /** Variable carrying the issuer as configured. */
-    @Nonnull
-    private static final String ISSUER_VARIABLE = "issuer";
-
-    /** Variable carrying scheme and authority of the issuer. */
-    @Nonnull
-    private static final String BASE_URL_VARIABLE = "baseUrl";
-
-    /** Variable carrying the host of the issuer. */
-    @Nonnull
-    private static final String HOST_VARIABLE = "host";
-
-    /** Reference Velocity leaves in place when it resolves to nothing. */
-    @Nonnull
-    private static final Pattern UNRESOLVED = Pattern.compile("\\$\\{?[A-Za-z_]");
-
-    /** Class logger. */
-    @Nonnull
-    private final Logger log = LoggerFactory.getLogger(FilesystemCredentialIssuerMetadataResolver.class);
-
-    /** Parser of the metadata file. */
-    @Nonnull
-    private final ObjectMapper objectMapper = new ObjectMapper();
-
-    /** Engine evaluating the metadata file, or null to read the file as it is. */
-    @Nullable
-    private VelocityEngine velocityEngine;
-
-    /** Issuer the variables of the template are derived from. */
-    @Nullable
-    private String issuer;
-
-    /** Variables the deployer adds to the template. */
-    @Nullable
-    private Map<String, ?> templateContext;
-
-    /** Variables offered to the template. */
-    @Nonnull
-    private Map<String, Object> variables = Map.of();
+public class FilesystemCredentialIssuerMetadataResolver
+        extends AbstractTemplatedFileMetadataResolver<CredentialIssuerMetadata>
+        implements CredentialIssuerMetadataResolver {
 
     /**
      * Constructor.
@@ -109,157 +46,11 @@ public class FilesystemCredentialIssuerMetadataResolver extends
         super(metadata);
     }
 
-    /**
-     * Set the engine evaluating the metadata file.
-     *
-     * Leaving this unset reads the file as it is, for a document that carries '$'
-     * or '#' as content.
-     *
-     * @param engine engine to use, or null to read the file as it is
-     */
-    public void setVelocityEngine(@Nullable final VelocityEngine engine) {
-        checkSetterPreconditions();
-        velocityEngine = engine;
-    }
-
-    /**
-     * Set the issuer the variables of the template are derived from.
-     *
-     * This is the deployment's issuer, so '$baseUrl' names this host even when the
-     * issuer itself carries a path.
-     *
-     * @param identifier issuer to derive the variables from
-     */
-    public void setIssuer(@Nullable final String identifier) {
-        checkSetterPreconditions();
-        issuer = identifier;
-    }
-
-    /**
-     * Set the variables the deployer adds to the template.
-     *
-     * These are applied after the derived ones, so a deployer may also replace
-     * those.
-     *
-     * @param context variables to add
-     */
-    public void setTemplateContext(@Nullable final Map<String, ?> context) {
-        checkSetterPreconditions();
-        templateContext = context;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    protected void doInitialize() throws ComponentInitializationException {
-        resolveVariables();
-        super.doInitialize();
-    }
-
-    /**
-     * Resolve the variables offered to the template.
-     *
-     * @throws ComponentInitializationException if the issuer does not name a host
-     */
-    private void resolveVariables() throws ComponentInitializationException {
-
-        if (velocityEngine == null) {
-            return;
-        }
-
-        final Map<String, Object> resolved = new LinkedHashMap<>();
-        if (issuer != null && !issuer.isEmpty()) {
-            final URI uri;
-            try {
-                uri = new URI(issuer);
-            } catch (final URISyntaxException e) {
-                throw new ComponentInitializationException("Issuer '" + issuer + "' is not a uri", e);
-            }
-            if (uri.getScheme() == null || uri.getRawAuthority() == null) {
-                throw new ComponentInitializationException("Issuer '" + issuer + "' names no host");
-            }
-            resolved.put(ISSUER_VARIABLE, issuer);
-            resolved.put(BASE_URL_VARIABLE, uri.getScheme() + "://" + uri.getRawAuthority());
-            resolved.put(HOST_VARIABLE, uri.getHost());
-        }
-        if (templateContext != null) {
-            resolved.putAll(templateContext);
-        }
-        variables = resolved;
-
-        log.debug("Credential Issuer metadata template variables {}", resolved);
-    }
-
-    /**
-     * Evaluate the metadata template.
-     *
-     * @param template template to evaluate
-     * @return the evaluated document
-     * @throws ParseException if the template cannot be evaluated
-     */
-    @Nonnull
-    private String evaluate(@Nonnull final String template) throws ParseException {
-
-        assert velocityEngine != null;
-
-        if (template.isBlank()) {
-            throw new ParseException("Credential Issuer metadata template is empty");
-        }
-
-        final String document;
-        Template compiled = null;
-        try {
-            compiled = Template.fromTemplate(velocityEngine, template, StandardCharsets.UTF_8);
-            final VelocityContext context = new VelocityContext();
-            variables.forEach(context::put);
-            document = compiled.merge(context);
-        } catch (final VelocityException e) {
-            throw new ParseException("Unable to evaluate Credential Issuer metadata template: " + e.getMessage(), e);
-        } finally {
-            // fromTemplate registers the template in a repository that outlives it.
-            if (compiled != null) {
-                StringResourceLoader.getRepository().removeStringResource(compiled.getTemplateName());
-            }
-        }
-
-        if (UNRESOLVED.matcher(document).find()) {
-            log.warn(
-                    "{} Credential Issuer metadata still reads as a template after evaluating it. A reference to "
-                            + "something other than {} resolves to nothing and is left in place.",
-                    getLogPrefix(), variables.keySet());
-        }
-
-        return document;
-    }
-
     /** {@inheritDoc} */
     @Override
     @Nonnull
-    public Iterable<CredentialIssuerMetadata> resolve(@Nullable final ProfileRequestContext profileRequestContext)
-            throws ResolverException {
-        ifNotInitializedThrowUninitializedComponentException();
-        ifDestroyedThrowDestroyedComponentException();
-
-        return getBackingStore().getOrderedInformation();
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    @Nullable
-    public CredentialIssuerMetadata resolveSingle(@Nullable final ProfileRequestContext profileRequestContext)
-            throws ResolverException {
-        final Iterator<CredentialIssuerMetadata> iterator = resolve(profileRequestContext).iterator();
-        if (iterator.hasNext()) {
-            return iterator.next();
-        }
-        log.warn("{} No Credential Issuer metadata has been read in", getLogPrefix());
-        return null;
-    }
-
-    /** {@inheritDoc} */
-    @Override
-    @Nonnull
-    protected List<CredentialIssuerMetadata> parse(@Nonnull final byte[] bytes) throws ParseException {
-        return CollectionSupport.listOf(CredentialIssuerMetadata.parse(readMembers(bytes)));
+    protected CredentialIssuerMetadata fromMembers(@Nonnull final Map<String, Object> members) throws ParseException {
+        return CredentialIssuerMetadata.parse(members);
     }
 
     /** {@inheritDoc} */
@@ -269,32 +60,4 @@ public class FilesystemCredentialIssuerMetadataResolver extends
         return value.getCredentialIssuer();
     }
 
-    /**
-     * Read the members of a metadata document.
-     *
-     * @param bytes document to read
-     * @return members of the document, in the order they appear in it
-     * @throws ParseException if the bytes are not a JSON object
-     */
-    @Nonnull
-    protected Map<String, Object> readMembers(@Nonnull final byte[] bytes) throws ParseException {
-
-        String document = new String(bytes, StandardCharsets.UTF_8);
-        if (velocityEngine != null) {
-            document = evaluate(document);
-        }
-
-        try {
-            final Map<String, Object> members = objectMapper.readValue(document,
-                    new TypeReference<LinkedHashMap<String, Object>>() {
-                    });
-            if (members == null) {
-                throw new ParseException("Credential Issuer metadata is not a JSON object");
-            }
-            return members;
-        } catch (final IOException e) {
-            throw new ParseException("Unable to parse Credential Issuer metadata: " + e.getMessage(), e);
-        }
-    }
-
 }
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolver.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolver.java
new file mode 100644
index 0000000..83713b0
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/metadata/impl/FilesystemMetadataDocumentResolver.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.metadata.impl;
+
+import java.io.IOException;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.MetadataDocumentResolver;
+import org.springframework.core.io.Resource;
+
+import com.nimbusds.oauth2.sdk.ParseException;
+import com.nimbusds.oauth2.sdk.id.Issuer;
+
+/**
+ * A metadata document read from a file, the document named by its 'issuer'
+ * member.
+ */
+public class FilesystemMetadataDocumentResolver
+        extends AbstractTemplatedFileMetadataResolver<MetadataDocument>
+        implements MetadataDocumentResolver {
+
+    /**
+     * Constructor.
+     *
+     * @param metadata the metadata file
+     *
+     * @throws IOException if the metadata cannot be loaded
+     */
+    public FilesystemMetadataDocumentResolver(@Nonnull final Resource metadata) throws IOException {
+        super(metadata);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected MetadataDocument fromMembers(@Nonnull final Map<String, Object> members) throws ParseException {
+        return MetadataDocument.parse(members);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    @Nonnull
+    protected Issuer getKey(@Nonnull final MetadataDocument value) {
+        return value.getIssuer();
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
new file mode 100644
index 0000000..c843be7
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.config.impl;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.idp.profile.config.AbstractInterceptorAwareProfileConfiguration;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+
+/**
+ * Profile configuration for an endpoint publishing a metadata document.
+ *
+ * There is nothing of the document to configure, so one class serves every such
+ * endpoint and the profile is the identifier given to it.
+ */
+public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwareProfileConfiguration {
+
+    /** ID for the JWT VC issuer metadata profile configuration. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_ID_JWT_VC_ISSUER = "http://geant.org/ns/profiles/oauth/jwt-vc-issuer";
+
+    /** ID for the authorization server metadata profile configuration. */
+    @Nonnull
+    @NotEmpty
+    public static final String PROFILE_ID_AUTHORIZATION_SERVER =
+            "http://geant.org/ns/profiles/oauth/authorization-server";
+
+    /**
+     * Constructor.
+     *
+     * @param id identifier of the profile this configuration is for
+     */
+    public DefaultMetadataDocumentConfiguration(@Nonnull @NotEmpty final String id) {
+        super(id);
+    }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
new file mode 100644
index 0000000..b33e05c
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundMetadataDocumentResponseMessage.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.MetadataDocumentSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.metadata.MetadataDocument;
+import org.geant.shibboleth.plugin.openidvci.metadata.resolver.MetadataDocumentResolver;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+import net.shibboleth.shared.resolver.ResolverException;
+
+/**
+ * Action forming {@link MetadataDocumentSuccessResponse} from the document the
+ * attached {@link MetadataDocumentResolver} resolves.
+ */
+public class FormOutboundMetadataDocumentResponseMessage extends AbstractProfileAction {
+
+    /** Class logger. */
+    @Nonnull
+    private final Logger log = LoggerFactory.getLogger(FormOutboundMetadataDocumentResponseMessage.class);
+
+    /** Resolver of the document that is being published. */
+    @NonnullAfterInit
+    private MetadataDocumentResolver metadataResolver;
+
+    /** Document to publish. */
+    @NonnullBeforeExec
+    private MetadataDocument document;
+
+    /**
+     * Set the resolver of the document that is being published.
+     *
+     * @param resolver resolver to use
+     */
+    public void setMetadataResolver(@Nonnull final MetadataDocumentResolver resolver) {
+        checkSetterPreconditions();
+        metadataResolver = Constraint.isNotNull(resolver, "The metadata resolver cannot be null");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doInitialize() throws ComponentInitializationException {
+        super.doInitialize();
+
+        if (metadataResolver == null) {
+            throw new ComponentInitializationException("The metadata resolver cannot be null");
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        if (!super.doPreExecute(profileRequestContext)) {
+            return false;
+        }
+
+        try {
+            document = metadataResolver.resolveSingle(profileRequestContext);
+        } catch (final ResolverException e) {
+            log.error("{} Could not resolve the metadata document", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return false;
+        }
+        if (document == null) {
+            log.error("{} Could not resolve the metadata document", getLogPrefix());
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return false;
+        }
+
+        return true;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+        assert document != null;
+
+        try {
+            profileRequestContext.ensureOutboundMessageContext()
+                    .setMessage(new MetadataDocumentSuccessResponse(document.getMembers()));
+        } catch (final JsonProcessingException e) {
+            log.error("{} Could not serialize the metadata document", getLogPrefix(), e);
+            ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+            return;
+        }
+
+        log.debug("{} Publishing the metadata document of {}, members {}", getLogPrefix(),
+                document.getIssuer(), document.getMembers().keySet());
+    }
+
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
index 6fa0ac5..4971545 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net.shibboleth.idp/postconfig.xml
@@ -96,4 +96,42 @@
     <bean id="openidvci.CredentialsResource" class="org.springframework.core.io.FileSystemResource" lazy-init="true"
         c:path="%{openidvci.credentialConfigurations.resource:%{idp.home}/metadata/verifiable-credentials.json}" />
 
+    <!--
+    JWT VC issuer metadata, published at /.well-known/jwt-vc-issuer. A Velocity template
+    with the same $issuer, $baseUrl and $host variables as the document above.
+    -->
+    <bean id="openidvci.DefaultJWTVCIssuerMetadataResolver" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemMetadataDocumentResolver"
+        depends-on="shibboleth.LoggingService"
+        p:id="openidvci.DefaultJWTVCIssuerMetadataResolver"
+        p:minRefreshDelay="%{openidvci.jwtVcIssuer.minRefreshDelay:PT5M}"
+        p:maxRefreshDelay="%{openidvci.jwtVcIssuer.maxRefreshDelay:PT4H}"
+        c:metadata="#{getObject('openidvci.JWTVCIssuerMetadata') ?: getObject('openidvci.JWTVCIssuerMetadataSkeleton')}"
+        p:velocityEngine="#{%{openidvci.jwtVcIssuer.templated:true} ? getObject('shibboleth.VelocityEngine') : null}"
+        p:issuer="#{getObject('shibboleth.oidc.issuer')}"
+        p:templateContext="#{getObject('openidvci.jwtVcIssuer.TemplateContext')}" />
+
+    <bean id="openidvci.JWTVCIssuerMetadataSkeleton" class="org.springframework.core.io.FileSystemResource"
+        lazy-init="true"
+        c:path="%{openidvci.jwtVcIssuer.template:%{idp.home}/static/jwt-vc-issuer.json}" />
+
+    <!--
+    Authorization server metadata, published at /.well-known/oauth-authorization-server. A
+    Velocity template as above, and everything in it is the deployer's to write.
+    -->
+    <bean id="openidvci.DefaultAuthorizationServerMetadataResolver" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.metadata.impl.FilesystemMetadataDocumentResolver"
+        depends-on="shibboleth.LoggingService"
+        p:id="openidvci.DefaultAuthorizationServerMetadataResolver"
+        p:minRefreshDelay="%{openidvci.authorizationServer.minRefreshDelay:PT5M}"
+        p:maxRefreshDelay="%{openidvci.authorizationServer.maxRefreshDelay:PT4H}"
+        c:metadata="#{getObject('openidvci.AuthorizationServerMetadata') ?: getObject('openidvci.AuthorizationServerMetadataSkeleton')}"
+        p:velocityEngine="#{%{openidvci.authorizationServer.templated:true} ? getObject('shibboleth.VelocityEngine') : null}"
+        p:issuer="#{getObject('shibboleth.oidc.issuer')}"
+        p:templateContext="#{getObject('openidvci.authorizationServer.TemplateContext')}" />
+
+    <bean id="openidvci.AuthorizationServerMetadataSkeleton" class="org.springframework.core.io.FileSystemResource"
+        lazy-init="true"
+        c:path="%{openidvci.authorizationServer.template:%{idp.home}/static/oauth-authorization-server.json}" />
+
 </beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
new file mode 100644
index 0000000..a1a963d
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+       default-init-method="initialize" default-destroy-method="destroy">
+
+  <bean id="openidvci.profileId" class="java.lang.String" c:_0="http://geant.org/ns/profiles/oauth/authorization-server"/>
+  <bean id="openidvci.loggingId" class="java.lang.String"
+        c:_0="%{openidvci.logging.authorization-server:OAuth.AuthorizationServer}"/>
+
+  <bean id="InitializeUnverifiedRelyingPartyContext"
+        class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+  <bean id="FormOutboundMessage"
+        class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundMetadataDocumentResponseMessage"
+        scope="prototype"
+        p:metadataResolver-ref="#{'%{openidvci.authorizationServer.resolver:openidvci.DefaultAuthorizationServerMetadataResolver}'.trim()}" />
+
+  <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+        scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+    <property name="eventContextLookupStrategy">
+      <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+    </property>
+    <property name="mappedErrors">
+      <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+        <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+      </map>
+    </property>
+  </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-flow.xml
new file mode 100644
index 0000000..984ad55
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-flow.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+      parent="openid/vci/abstract-api">
+
+  <action-state id="InitializeMandatoryContexts">
+    <evaluate expression="InitializeProfileRequestContext" />
+    <evaluate expression="PopulateMetricContext" />
+    <evaluate expression="FlowStartPopulateAuditContext" />
+    <evaluate expression="InitializeOutboundMessageContext" />
+    <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
+    <evaluate expression="SelectRelyingPartyConfiguration" />
+    <evaluate expression="SelectProfileConfiguration" />
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="BuildResponseMessage" />
+  </action-state>
+
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/authorization-server/authorization-server-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
new file mode 100644
index 0000000..595644a
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+                           http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+                           http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+       default-init-method="initialize" default-destroy-method="destroy">
+
+  <bean id="openidvci.profileId" class="java.lang.String" c:_0="http://geant.org/ns/profiles/oauth/jwt-vc-issuer"/>
+  <bean id="openidvci.loggingId" class="java.lang.String"
+        c:_0="%{openidvci.logging.jwt-vc-issuer:OAuth.JWTVCIssuer}"/>
+
+  <bean id="InitializeUnverifiedRelyingPartyContext"
+        class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+  <bean id="FormOutboundMessage"
+        class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundMetadataDocumentResponseMessage"
+        scope="prototype"
+        p:metadataResolver-ref="#{'%{openidvci.jwtVcIssuer.resolver:openidvci.DefaultJWTVCIssuerMetadataResolver}'.trim()}" />
+
+  <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+        scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
+        p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+    <property name="eventContextLookupStrategy">
+      <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+    </property>
+    <property name="mappedErrors">
+      <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+        <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+               value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+      </map>
+    </property>
+  </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
new file mode 100644
index 0000000..e4bfe05
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-flow.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+      xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+      parent="openid/vci/abstract-api">
+
+  <action-state id="InitializeMandatoryContexts">
+    <evaluate expression="InitializeProfileRequestContext" />
+    <evaluate expression="PopulateMetricContext" />
+    <evaluate expression="FlowStartPopulateAuditContext" />
+    <evaluate expression="InitializeOutboundMessageContext" />
+    <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
+    <evaluate expression="SelectRelyingPartyConfiguration" />
+    <evaluate expression="SelectProfileConfiguration" />
+    <evaluate expression="'proceed'" />
+
+    <transition on="proceed" to="BuildResponseMessage" />
+  </action-state>
+
+  <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/jwt-vc-issuer/jwt-vc-issuer-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index 4a3447e..dcca201 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -57,6 +57,20 @@
         p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
         p:issuerMetadataSignatureSigningConfiguration-ref="openidvci.issuerMetadata.SigningConfiguration" />
 
+    <!--
+    Endpoints publishing a metadata document. They serve anyone, so there is nothing of the
+    VCI profile settings to inherit and nothing of the document to configure here.
+    -->
+    <bean id="OAuth.JWTVCIssuer" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration"
+        c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_JWT_VC_ISSUER}"
+        p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
+    <bean id="OAuth.AuthorizationServer" lazy-init="true"
+        class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration"
+        c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_AUTHORIZATION_SERVER}"
+        p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
     <bean id="OAuth.StatusList" lazy-init="true"
         class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.config.impl.DefaultStatusListConfiguration"
         p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index 5bb09eb..919a7d0 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -71,6 +71,32 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 #openidvci.status-list.assignment.StorageService = shibboleth.StorageService
 #openidvci.status-list.index.StorageService = shibboleth.StorageService
 
+# Settings for the jwt-vc-issuer flow
+# Flow is available at /openid/vci/../oauth/jwt-vc-issuer, usually it should be wired from
+# /.well-known/jwt-vc-issuer. A verifier reads that document to find the keys of the issuer, but
+# only when a credential names its issuer by url. The default Credential Issuer identifier is
+# did:jwk, which carries the key inside the credential, so nothing reads the document until you
+# set openidvci.issuer.
+#openidvci.jwtVcIssuer.template = %{idp.home}/static/jwt-vc-issuer.json
+# The template is Velocity, its variables are the same $issuer, $baseUrl and $host as those of
+# the Credential Issuer metadata. Set false if the document contains literal '$' or '#'.
+#openidvci.jwtVcIssuer.templated = true
+#openidvci.jwtVcIssuer.resolver = openidvci.DefaultJWTVCIssuerMetadataResolver
+# Bounds on the next file refresh of the document
+#openidvci.jwtVcIssuer.minRefreshDelay = PT5M
+#openidvci.jwtVcIssuer.maxRefreshDelay = PT4H
+
+# Settings for the authorization-server flow
+# Flow is available at /oauth/authorization-server, usually it should be wired from
+# /.well-known/oauth-authorization-server. A wallet reads that document to find the endpoints of
+# the authorization server this deployment is.
+#openidvci.authorizationServer.template = %{idp.home}/static/oauth-authorization-server.json
+#openidvci.authorizationServer.templated = true
+#openidvci.authorizationServer.resolver = openidvci.DefaultAuthorizationServerMetadataResolver
+# Bounds on the next file refresh of the document
+#openidvci.authorizationServer.minRefreshDelay = PT5M
+#openidvci.authorizationServer.maxRefreshDelay = PT4H
+
 # Settings for stating this deployment as a Credential Issuer in OpenID Federation. Active
 # only when conf/openid-vci-oidfed.xml is imported into conf/global.xml. Set to false to
 # state federation_entity entirely in the federation module's own metadata file instead of
@@ -83,6 +109,8 @@ openidvci.status-list.index.permutationKey = change_this_to_secret
 #openidvci.logging.token = OpenID.VCI.Token
 #openidvci.logging.nonce = OpenID.VCI.Nonce
 #openidvci.logging.issuer-metadata = OpenID.VCI.IssuerMetadata
+#openidvci.logging.jwt-vc-issuer = OAuth.JWTVCIssuer
+#openidvci.logging.authorization-server = OAuth.AuthorizationServer
 #openidvci.logging.status-list.list = OAuth.StatusList
 #openidvci.logging.status-list.revoke = OAuth.StatusList.Revoke
 #openidvci.logging.status-list.assignments = OAuth.StatusList.Assignments
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
index ec6b0f0..a200663 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
@@ -38,11 +38,12 @@ it is a Velocity template evaluated against the issuer of the OP. Credential Con
 kept in that file, they are published from metadata/verifiable-credentials.json. Signed metadata \
 is served in addition to the unsigned document, to a wallet asking for application/jwt, only once \
 you enable the metadata signing key in conf/openid-vci-credentials.xml. \
-Wallets read also /.well-known/jwt-vc-issuer and /.well-known/oauth-authorization-server, which \
-this plugin does not serve. Publish static/jwt-vc-issuer.json and \
-static/oauth-authorization-server.json in those paths yourself. Replace {{ service_name }} in \
-both with your host and add every credential configuration id you want requestable with the \
-authorization code flow to scopes_supported of the latter. \
+Wire /.well-known/jwt-vc-issuer to /idp/profile/oauth/jwt-vc-issuer and \
+/.well-known/oauth-authorization-server to /idp/profile/oauth/authorization-server to publish the \
+other two documents a wallet reads. Urls in static/jwt-vc-issuer.json and \
+static/oauth-authorization-server.json name your host already, both are Velocity templates \
+evaluated against the issuer of the OP. Add every credential configuration id you want \
+requestable with the authorization code flow to scopes_supported of the latter. \
 If you run the OpenID Federation plugin, add <import resource="openid-vci-oidfed.xml" /> in \
 conf/global.xml to state this deployment as a Credential Issuer in its Entity Configuration. Do \
 not import it without that plugin, the IdP will not start.
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/jwt-vc-issuer.json b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/jwt-vc-issuer.json
index ec79c45..dfb451f 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/jwt-vc-issuer.json
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/jwt-vc-issuer.json
@@ -1,4 +1,4 @@
 {
-   "issuer":"https://{{ service_name }}",
-   "jwks_uri":"https://{{ service_name }}/idp/profile/oidc/keyset"
+   "issuer":"$issuer",
+   "jwks_uri":"$baseUrl/idp/profile/oidc/keyset"
 }
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/oauth-authorization-server.json b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/oauth-authorization-server.json
index 54d368f..0789d4e 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/oauth-authorization-server.json
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/static/oauth-authorization-server.json
@@ -1,9 +1,9 @@
 {
-   "issuer":"https://{{ service_name }}",
-   "authorization_endpoint":"https://{{ service_name }}/idp/profile/oidc/authorize",
-   "pushed_authorization_request_endpoint":"https://{{ service_name }}/idp/profile/oauth2/pushed-authorization",
-   "token_endpoint":"https://{{ service_name }}/idp/profile/openid/vci/token",
-   "jwks_uri":"https://{{ service_name }}/idp/profile/oidc/keyset",
+   "issuer":"$issuer",
+   "authorization_endpoint":"$baseUrl/idp/profile/oidc/authorize",
+   "pushed_authorization_request_endpoint":"$baseUrl/idp/profile/oauth2/pushed-authorization",
+   "token_endpoint":"$baseUrl/idp/profile/openid/vci/token",
+   "jwks_uri":"$baseUrl/idp/profile/oidc/keyset",
    "token_endpoint_auth_methods_supported":[
       "none",
       "client_secret_basic",

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list