├── .github └── workflows │ └── continuous_deployment.yaml ├── .gitignore ├── LICENSE ├── README.md ├── agent ├── .dockerignore ├── .env.development ├── .env.example ├── Dockerfile ├── package.json ├── src │ ├── agent.ts │ ├── constants.ts │ ├── did │ │ ├── cheqd.ts │ │ ├── createKeys.ts │ │ ├── index.ts │ │ ├── jwk.ts │ │ ├── key.ts │ │ ├── setup.ts │ │ ├── util.ts │ │ └── web.ts │ ├── endpoints.ts │ ├── issuer.ts │ ├── issuerMetadata.ts │ ├── server.ts │ ├── session.ts │ └── verifier.ts └── tsconfig.json ├── app ├── .env.example ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── app │ ├── globals.css │ ├── icon.svg │ ├── layout.tsx │ └── page.tsx ├── components.json ├── components │ ├── IssueTab.tsx │ ├── ReceiveTab.tsx │ ├── VerifyTab.tsx │ ├── highLight.module.css │ ├── highLight.tsx │ ├── main.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ ├── tabs.tsx │ │ ├── tooltip.tsx │ │ └── typography.tsx ├── lib │ ├── api.ts │ ├── constants.ts │ ├── hooks.ts │ └── utils.ts ├── next.config.js ├── nginx.conf ├── package.json ├── postcss.config.js ├── public │ └── logo.svg ├── tailwind.config.ts └── tsconfig.json ├── assets └── preview.png ├── docker-compose.yml ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── yarn.lock /.github/workflows/continuous_deployment.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Deployment 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | build: 7 | default: true 8 | type: boolean 9 | required: false 10 | description: Build the website before deploying 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_NAME: ${{ github.repository }} 14 | APP: app 15 | AGENT: agent 16 | 17 | jobs: 18 | build-and-push-image-agent: 19 | runs-on: ubuntu-latest 20 | if: inputs.build == true && github.ref == 'refs/heads/main' 21 | permissions: 22 | contents: read 23 | packages: write 24 | 25 | defaults: 26 | run: 27 | working-directory: ${{ env.AGENT }} 28 | 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | 33 | - name: Log in to the Container registry 34 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Extract metadata (tags, labels) for Docker 41 | id: meta 42 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 43 | with: 44 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ env.AGENT }} 45 | 46 | - name: Build and push Docker image 47 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 48 | with: 49 | context: ./agent 50 | push: true 51 | tags: ${{ steps.meta.outputs.tags }} 52 | labels: ${{ steps.meta.outputs.labels }} 53 | 54 | build-and-push-image-app: 55 | runs-on: ubuntu-latest 56 | if: inputs.build == true && github.ref == 'refs/heads/main' 57 | permissions: 58 | contents: read 59 | packages: write 60 | 61 | defaults: 62 | run: 63 | working-directory: ${{ env.APP }} 64 | 65 | steps: 66 | - name: Checkout repository 67 | uses: actions/checkout@v2 68 | 69 | - uses: pnpm/action-setup@v2 70 | with: 71 | version: 8 72 | 73 | - name: Install dependencies 74 | run: pnpm install --no-frozen-lockfile 75 | 76 | - run: NEXT_PUBLIC_API_URL=https://openid4vc.animo.id pnpm build 77 | 78 | - name: Log in to the Container registry 79 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 80 | with: 81 | registry: ${{ env.REGISTRY }} 82 | username: ${{ github.actor }} 83 | password: ${{ secrets.GITHUB_TOKEN }} 84 | 85 | - name: Extract metadata (tags, labels) for Docker 86 | id: meta 87 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 88 | with: 89 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/${{ env.APP }} 90 | 91 | - name: Build and push Docker image 92 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 93 | with: 94 | context: ./app 95 | push: true 96 | tags: ${{ steps.meta.outputs.tags }} 97 | labels: ${{ steps.meta.outputs.labels }} 98 | 99 | deploy: 100 | # Only run on main branch 101 | runs-on: ubuntu-latest 102 | needs: [build-and-push-image-agent, build-and-push-image-app] 103 | if: | 104 | always() && 105 | (needs.build-and-push-image-agent.result == 'success' || needs.build-and-push-image-agent.result == 'skipped') && 106 | (needs.build-and-push-image-app.result == 'success' || needs.build-and-push-image-app.result == 'skipped') && 107 | github.ref == 'refs/heads/main' 108 | steps: 109 | - name: Checkout repository 110 | uses: actions/checkout@v2 111 | 112 | - name: Copy stack file to remote 113 | uses: garygrossgarten/github-action-scp@v0.7.3 114 | with: 115 | local: docker-compose.yml 116 | remote: openid4vc-playground/docker-compose.yml 117 | host: dashboard.dev.animo.id 118 | username: root 119 | privateKey: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }} 120 | 121 | - name: Deploy to Docker Swarm via SSH action 122 | uses: appleboy/ssh-action@v0.1.4 123 | env: 124 | AGENT_WALLET_KEY: ${{ secrets.AGENT_WALLET_KEY }} 125 | CHEQD_TESTNET_COSMOS_PAYER_SEED: ${{ secrets.CHEQD_TESTNET_COSMOS_PAYER_SEED }} 126 | DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED: ${{ secrets.DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED }} 127 | P256_SEED: ${{ secrets.P256_SEED }} 128 | ED25519_SEED: ${{ secrets.ED25519_SEED }} 129 | with: 130 | host: dashboard.dev.animo.id 131 | username: root 132 | key: ${{ secrets.DOCKER_SSH_PRIVATE_KEY }} 133 | envs: P256_SEED,ED25519_SEED,AGENT_WALLET_KEY,DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED,CHEQD_TESTNET_COSMOS_PAYER_SEED 134 | script: | 135 | ED25519_SEED=${ED25519_SEED} P256_SEED=${P256_SEED} AGENT_WALLET_KEY=${AGENT_WALLET_KEY} CHEQD_TESTNET_COSMOS_PAYER_SEED=${CHEQD_TESTNET_COSMOS_PAYER_SEED} DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED=${DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED} docker stack deploy --compose-file openid4vc-playground/docker-compose.yml openid4vc-playground --with-registry-auth 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | .env 5 | .env.local -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020-present Hyperledger Contributors. 190 | Copyright 2021 Queen’s Printer for Ontario. Mostafa Youssef (https://github.com/MosCD3), Amit Padmani (https://github.com/nbAmit), Prasad Katkar (https://github.com/NB-PrasadKatkar), Mike Richardson (https://github.com/NB-MikeRichardson) 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | screenshot-demo 4 | 5 |

6 | 7 |

Animo OpenID4VC Playground

8 |
9 | License 15 |
16 | 17 | > [!TIP] 18 | > Check out the demo at https://openid4vc.animo.id 19 | 20 | ## ✨ Hi there! 21 | 22 | Welcome to the repository of Animo's OpenID4VC Playground. This interactive playground demonstrates the use of OpenID4VC with different credential formats (such as SD-JWT VCs). This demo is built using [Aries Framework Javascript (AFJ)](https://github.com/hyperledger/aries-framework-javascript). AFJ is a framework written in TypeScript for building decentralized identity services. 23 | 24 | ## 🛠️ Usage 25 | 26 | ### Prerequisites 27 | 28 | - [NodeJS](https://nodejs.org/en/) v18.X.X - Other versions may work, not tested 29 | - [pnpm](https://pnpm.io/installation) 30 | - [Git](https://git-scm.com/downloads) - You probably already have this 31 | 32 | ### 🖥 App 33 | 34 | Copy the `.env.example` file to a `.env.local` file and set the environment variables. **This is not needed for development**. 35 | 36 | ```bash 37 | cd app 38 | cp .env.example .env 39 | ``` 40 | 41 | | Variable | Description | 42 | | --------------------- | ----------------------------------------------------------------------------------------------------------------- | 43 | | `NEXT_PUBLIC_API_URL` | Used in the frontend application to connect with the backend. Default to `http://localhost:3001` for development. | 44 | 45 | ### 🎛️ Agent 46 | 47 | Copy the `.env.example` file to a `.env.local` file and set the environment variables. **This is not needed for development**. 48 | 49 | ```bash 50 | cd agent 51 | cp .env.example .env 52 | ``` 53 | 54 | | Variable | Description | 55 | | ------------------ | --------------------------------------------------------------------------------------------- | 56 | | `AGENT_HOST` | Used in the backend application for the agent. The url at which the server will be available. | 57 | | `AGENT_WALLET_KEY` | Used in the backend application for the agent. Should be secure and kept private. | 58 | 59 | > [!IMPORTANT] 60 | > The issuer will use `did:web` for issuing credentials, but this requires `https` to be used. When developing locally it is recommend 61 | > to use `ngrok` (`npx ngrok http 3001`) and use that url as the `AGENT_HOST` variable. Make sure to also set the `NEXT_PUBLIC_API_URL` variable in the app to the ngrok. 62 | > 63 | > We may add issuance using did:key in development if the host url does not start with `https`. 64 | 65 | ### Install Dependencies 66 | 67 | ```bash 68 | pnpm install 69 | ``` 70 | 71 | ### Development 72 | 73 | Open three terminal windows, and then run the following: 74 | 75 | ```bash 76 | npx ngrok http 3001 77 | ``` 78 | 79 | Copy the https url from the ngrok command and set that as the `AGENT_HOST` 80 | 81 | ```bash 82 | cd agent 83 | AGENT_HOST=https://30f9-58-136-114-148.ngrok-free.app pnpm dev 84 | ``` 85 | 86 | ```bash 87 | cd app 88 | pnpm dev 89 | ``` 90 | 91 | ## 🖇️ How To Contribute 92 | 93 | You're welcome to contribute to this playground. Please make sure to open an issue first! 94 | 95 | This playground is open source and you're more than welcome to customize and use it to create your own OpenID4VC Playground. If you do, an attribution to [Animo](https://animo.id) would be very much appreciated! 96 | -------------------------------------------------------------------------------- /agent/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | dist -------------------------------------------------------------------------------- /agent/.env.development: -------------------------------------------------------------------------------- 1 | AGENT_WALLET_KEY=secret-wallet-key 2 | DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED=2543786a945a27258087ccfe95ff62df 3 | CHEQD_TESTNET_COSMOS_PAYER_SEED=robust across amount corn curve panther opera wish toe ring bleak empower wreck party abstract glad average muffin picnic jar squeeze annual long aunt 4 | ED25519_SEED=5473a3e4c5ae3fd5fb3ad089563596e3 5 | P256_SEED=e5f18b10cd15cdb76818bc6ae8b71eb475e6eac76875ed085d3962239bbcf42f -------------------------------------------------------------------------------- /agent/.env.example: -------------------------------------------------------------------------------- 1 | AGENT_HOST=http://localhost:3001 2 | AGENT_WALLET_KEY=secret-wallet-key 3 | DID_INDY_INDICIO_TESTNET_PUBLIC_DID_SEED=2543786a945a27258087ccfe95ff62df 4 | CHEQD_TESTNET_COSMOS_PAYER_SEED=robust across amount corn curve panther opera wish toe ring bleak empower wreck party abstract glad average muffin picnic jar squeeze annual long aunt 5 | ED25519_SEED=5473a3e4c5ae3fd5fb3ad089563596e3 6 | P256_SEED=e5f18b10cd15cdb76818bc6ae8b71eb475e6eac76875ed085d3962239bbcf42f -------------------------------------------------------------------------------- /agent/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 as base 2 | 3 | COPY package.json /app/package.json 4 | 5 | ENV PNPM_HOME="/pnpm" 6 | ENV PATH="$PNPM_HOME:$PATH" 7 | RUN corepack enable 8 | 9 | WORKDIR /app 10 | 11 | FROM base AS prod-deps 12 | COPY tsconfig.json /app/tsconfig.json 13 | 14 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod 15 | 16 | FROM base AS build 17 | COPY tsconfig.json /app/tsconfig.json 18 | 19 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install 20 | COPY src src 21 | RUN pnpm run build 22 | 23 | FROM base 24 | 25 | COPY --from=prod-deps /app/node_modules /app/node_modules 26 | COPY --from=build /app/dist /app/dist 27 | 28 | EXPOSE 3000 29 | CMD [ "pnpm", "start" ] -------------------------------------------------------------------------------- /agent/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agent", 3 | "dependencies": { 4 | "@credo-ts/askar": "^0.5.10", 5 | "@credo-ts/cheqd": "^0.5.10", 6 | "@credo-ts/core": "^0.5.10", 7 | "@credo-ts/indy-vdr": "^0.5.10", 8 | "@credo-ts/node": "^0.5.10", 9 | "@credo-ts/openid4vc": "^0.5.10", 10 | "@hyperledger/aries-askar-nodejs": "^0.2.3", 11 | "cors": "^2.8.5", 12 | "express": "^4.18.2", 13 | "zod": "^3.22.4" 14 | }, 15 | "devDependencies": { 16 | "@types/cors": "^2.8.17", 17 | "@types/express": "^4.17.21", 18 | "tsx": "^4.7.0", 19 | "typescript": "^5.3.3" 20 | }, 21 | "scripts": { 22 | "build": "tsc -p tsconfig.json", 23 | "start": "node dist/server.js", 24 | "dev": "tsx watch -r dotenv/config src/server.ts dotenv_config_path=.env.development" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /agent/src/agent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Agent, 3 | ConsoleLogger, 4 | DidsModule, 5 | JwkDidRegistrar, 6 | JwkDidResolver, 7 | KeyDidRegistrar, 8 | KeyDidResolver, 9 | LogLevel, 10 | WebDidResolver, 11 | joinUriParts, 12 | } from "@credo-ts/core"; 13 | import { agentDependencies } from "@credo-ts/node"; 14 | import { AskarModule } from "@credo-ts/askar"; 15 | import { 16 | CheqdModule, 17 | CheqdDidRegistrar, 18 | CheqdDidResolver, 19 | } from "@credo-ts/cheqd"; 20 | import { ariesAskar } from "@hyperledger/aries-askar-nodejs"; 21 | import { 22 | OpenId4VcHolderModule, 23 | OpenId4VcIssuerModule, 24 | OpenId4VcVerifierModule, 25 | } from "@credo-ts/openid4vc"; 26 | import { 27 | AGENT_HOST, 28 | AGENT_WALLET_KEY, 29 | CHEQD_TESTNET_COSMOS_PAYER_SEED, 30 | } from "./constants"; 31 | import { Router } from "express"; 32 | import { credentialRequestToCredentialMapper } from "./issuer"; 33 | 34 | process.on("unhandledRejection", (reason) => { 35 | console.log("Unhandled rejection", reason); 36 | }); 37 | 38 | export const openId4VciRouter = Router(); 39 | export const openId4VpRouter = Router(); 40 | 41 | export const agent = new Agent({ 42 | dependencies: agentDependencies, 43 | config: { 44 | label: "OpenID4VC Playground", 45 | logger: new ConsoleLogger(LogLevel.trace), 46 | // TODO: add postgres storage 47 | walletConfig: { 48 | id: "openid4vc-playground", 49 | key: AGENT_WALLET_KEY, 50 | }, 51 | }, 52 | modules: { 53 | cheqd: new CheqdModule({ 54 | networks: [ 55 | { 56 | network: "testnet", 57 | cosmosPayerSeed: CHEQD_TESTNET_COSMOS_PAYER_SEED, 58 | }, 59 | ], 60 | }), 61 | dids: new DidsModule({ 62 | resolvers: [ 63 | new KeyDidResolver(), 64 | new JwkDidResolver(), 65 | new WebDidResolver(), 66 | new CheqdDidResolver(), 67 | ], 68 | registrars: [ 69 | new KeyDidRegistrar(), 70 | new JwkDidRegistrar(), 71 | new CheqdDidRegistrar(), 72 | ], 73 | }), 74 | askar: new AskarModule({ 75 | ariesAskar, 76 | }), 77 | openId4VcIssuer: new OpenId4VcIssuerModule({ 78 | baseUrl: joinUriParts(AGENT_HOST, ["oid4vci"]), 79 | router: openId4VciRouter, 80 | endpoints: { 81 | credential: { 82 | credentialRequestToCredentialMapper, 83 | }, 84 | }, 85 | }), 86 | openId4VcHolder: new OpenId4VcHolderModule(), 87 | openId4VcVerifier: new OpenId4VcVerifierModule({ 88 | baseUrl: joinUriParts(AGENT_HOST, ["siop"]), 89 | router: openId4VpRouter, 90 | }), 91 | }, 92 | }); 93 | -------------------------------------------------------------------------------- /agent/src/constants.ts: -------------------------------------------------------------------------------- 1 | if ( 2 | !process.env.ED25519_SEED || 3 | !process.env.P256_SEED || 4 | !process.env.AGENT_HOST || 5 | !process.env.AGENT_WALLET_KEY 6 | ) { 7 | throw new Error( 8 | "ED25519_SEED, P256_SEED, AGENT_HOST or AGENT_WALLET_KEY env variable not set" 9 | ); 10 | } 11 | 12 | const AGENT_HOST = process.env.AGENT_HOST; 13 | const AGENT_WALLET_KEY = process.env.AGENT_WALLET_KEY; 14 | 15 | const CHEQD_TESTNET_COSMOS_PAYER_SEED = 16 | process.env.CHEQD_TESTNET_COSMOS_PAYER_SEED; 17 | 18 | const ED25519_SEED = process.env.ED25519_SEED; 19 | const P256_SEED = process.env.P256_SEED; 20 | 21 | export { 22 | AGENT_HOST, 23 | AGENT_WALLET_KEY, 24 | CHEQD_TESTNET_COSMOS_PAYER_SEED, 25 | ED25519_SEED, 26 | P256_SEED, 27 | }; 28 | -------------------------------------------------------------------------------- /agent/src/did/cheqd.ts: -------------------------------------------------------------------------------- 1 | import { agent } from "../agent"; 2 | import { CheqdDidCreateOptions } from "@credo-ts/cheqd"; 3 | import { 4 | DidDocumentBuilder, 5 | utils, 6 | KeyType, 7 | getEd25519VerificationKey2018, 8 | } from "@credo-ts/core"; 9 | 10 | export async function createDidCheqd() { 11 | // NOTE: we need to pass custom document for cheqd if we want to add it to `assertionMethod` 12 | 13 | const did = `did:cheqd:testnet:${utils.uuid()}`; 14 | const key = await agent.wallet.createKey({ 15 | keyType: KeyType.Ed25519, 16 | }); 17 | 18 | const ed25519VerificationMethod = getEd25519VerificationKey2018({ 19 | key, 20 | id: `${did}#key-1`, 21 | controller: did, 22 | }); 23 | 24 | const didDocument = new DidDocumentBuilder(did) 25 | .addContext("https://w3id.org/security/suites/ed25519-2018/v1") 26 | .addVerificationMethod(ed25519VerificationMethod) 27 | .addAssertionMethod(ed25519VerificationMethod.id) 28 | .addAuthentication(ed25519VerificationMethod.id) 29 | .build(); 30 | 31 | const didResult = await agent.dids.create({ 32 | method: "cheqd", 33 | didDocument, 34 | secret: {}, 35 | }); 36 | 37 | if (didResult.didState.state === "failed") { 38 | throw new Error(`cheqd DID creation failed. ${didResult.didState.reason}`); 39 | } 40 | 41 | return [did]; 42 | } 43 | -------------------------------------------------------------------------------- /agent/src/did/createKeys.ts: -------------------------------------------------------------------------------- 1 | import { KeyType, TypedArrayEncoder } from "@credo-ts/core"; 2 | import { agent } from "../agent"; 3 | import { ED25519_SEED, P256_SEED } from "../constants"; 4 | 5 | export async function createKeys() { 6 | const ed25519Key = await agent.wallet.createKey({ 7 | keyType: KeyType.Ed25519, 8 | seed: TypedArrayEncoder.fromString(ED25519_SEED), 9 | }); 10 | 11 | const p256Key = await agent.wallet.createKey({ 12 | keyType: KeyType.P256, 13 | seed: TypedArrayEncoder.fromString(P256_SEED), 14 | }); 15 | 16 | return [ed25519Key, p256Key]; 17 | } 18 | -------------------------------------------------------------------------------- /agent/src/did/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./util"; 2 | export * from "./web"; 3 | export * from "./jwk"; 4 | export * from "./key"; 5 | export * from "./cheqd"; 6 | export * from "./setup"; 7 | -------------------------------------------------------------------------------- /agent/src/did/jwk.ts: -------------------------------------------------------------------------------- 1 | import { DidJwk, Key, getJwkFromKey } from "@credo-ts/core"; 2 | import { agent } from "../agent"; 3 | 4 | export async function createDidJwk(keys: Key[]) { 5 | const createdDids: string[] = []; 6 | for (const key of keys) { 7 | const didJwk = DidJwk.fromJwk(getJwkFromKey(key)); 8 | await agent.dids.import({ 9 | overwrite: true, 10 | did: didJwk.did, 11 | }); 12 | createdDids.push(didJwk.did); 13 | } 14 | 15 | return createdDids; 16 | } 17 | -------------------------------------------------------------------------------- /agent/src/did/key.ts: -------------------------------------------------------------------------------- 1 | import { Key, DidKey } from "@credo-ts/core"; 2 | import { agent } from "../agent"; 3 | 4 | export async function createDidKey(keys: Key[]) { 5 | const createdDids: string[] = []; 6 | 7 | for (const key of keys) { 8 | const didKey = new DidKey(key); 9 | await agent.dids.import({ 10 | overwrite: true, 11 | did: didKey.did, 12 | }); 13 | createdDids.push(didKey.did); 14 | } 15 | 16 | return createdDids; 17 | } 18 | -------------------------------------------------------------------------------- /agent/src/did/setup.ts: -------------------------------------------------------------------------------- 1 | import { WalletKeyExistsError } from "@credo-ts/core"; 2 | import { createDidCheqd } from "./cheqd"; 3 | import { createKeys } from "./createKeys"; 4 | import { createDidJwk } from "./jwk"; 5 | import { createDidKey } from "./key"; 6 | import { createDidWeb } from "./web"; 7 | import { agent } from "../agent"; 8 | 9 | const availableDids: string[] = []; 10 | 11 | export async function setupAllDids() { 12 | try { 13 | const keys = await createKeys(); 14 | 15 | availableDids.push(...(await createDidKey(keys))); 16 | availableDids.push(...(await createDidJwk(keys))); 17 | availableDids.push(...(await createDidWeb(keys))); 18 | availableDids.push(...(await createDidCheqd())); 19 | 20 | await agent.genericRecords.save({ 21 | id: "AVAILABLE_DIDS", 22 | content: { 23 | availableDids, 24 | }, 25 | }); 26 | } catch (error) { 27 | // If the key already exists, we assume the dids are already created 28 | if (error instanceof WalletKeyExistsError) { 29 | const availableDidsRecord = await agent.genericRecords.findById( 30 | "AVAILABLE_DIDS" 31 | ); 32 | if (!availableDidsRecord) { 33 | throw new Error("No available dids record found"); 34 | } 35 | availableDids.push( 36 | ...(availableDidsRecord.content.availableDids as string[]) 37 | ); 38 | 39 | return; 40 | } 41 | 42 | throw error; 43 | } 44 | } 45 | 46 | export function getAvailableDids() { 47 | return availableDids; 48 | } 49 | -------------------------------------------------------------------------------- /agent/src/did/util.ts: -------------------------------------------------------------------------------- 1 | import { agent } from "../agent"; 2 | 3 | export async function hasDidForMethod(method: string) { 4 | const [createdDid] = await agent.dids.getCreatedDids({ method }); 5 | 6 | return createdDid !== undefined; 7 | } 8 | 9 | export async function getDidForMethod(method: string) { 10 | const [createdDid] = await agent.dids.getCreatedDids({ method }); 11 | 12 | if (!createdDid) { 13 | throw new Error(`did for method ${method} does not exist`); 14 | } 15 | 16 | return createdDid.did; 17 | } 18 | -------------------------------------------------------------------------------- /agent/src/did/web.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DidDocumentBuilder, 3 | Key, 4 | getKeyDidMappingByKeyType, 5 | } from "@credo-ts/core"; 6 | import { agent } from "../agent"; 7 | import { AGENT_HOST } from "../constants"; 8 | 9 | const cleanHost = encodeURIComponent( 10 | AGENT_HOST.replace("https://", "").replace("http://", "") 11 | ); 12 | 13 | const didWeb = `did:web:${cleanHost}`; 14 | 15 | export async function createDidWeb(keys: Key[]) { 16 | const verificationMethods = keys.flatMap((key) => 17 | getKeyDidMappingByKeyType(key.keyType).getVerificationMethods(didWeb, key) 18 | ); 19 | 20 | const didDocumentBuilder = new DidDocumentBuilder(didWeb); 21 | 22 | for (const verificationMethod of verificationMethods) { 23 | didDocumentBuilder 24 | .addVerificationMethod(verificationMethod) 25 | .addAssertionMethod(verificationMethod.id) 26 | .addAuthentication(verificationMethod.id); 27 | } 28 | 29 | const didDocument = didDocumentBuilder.build(); 30 | 31 | await agent.dids.import({ 32 | did: didWeb, 33 | didDocument, 34 | }); 35 | 36 | return [didWeb]; 37 | } 38 | 39 | export async function getWebDidDocument() { 40 | const [createdDid] = await agent.dids.getCreatedDids({ did: didWeb }); 41 | 42 | if (!createdDid || !createdDid.didDocument) { 43 | throw new Error('did does not exist'); 44 | } 45 | 46 | return createdDid.didDocument; 47 | } 48 | -------------------------------------------------------------------------------- /agent/src/endpoints.ts: -------------------------------------------------------------------------------- 1 | import { agent } from "./agent"; 2 | import express, { NextFunction, Request, Response } from "express"; 3 | import z from "zod"; 4 | import { credentialSupportedIds } from "./issuerMetadata"; 5 | import { getIssuer } from "./issuer"; 6 | import { 7 | DifPresentationExchangeService, 8 | JsonTransformer, 9 | KeyDidCreateOptions, 10 | KeyType, 11 | RecordNotFoundError, 12 | W3cJsonLdVerifiableCredential, 13 | W3cJsonLdVerifiablePresentation, 14 | W3cJwtVerifiableCredential, 15 | W3cJwtVerifiablePresentation, 16 | getJwkFromKey, 17 | getKeyFromVerificationMethod, 18 | parseDid, 19 | } from "@credo-ts/core"; 20 | import { getAvailableDids, getWebDidDocument } from "./did"; 21 | import { getVerifier } from "./verifier"; 22 | import { OpenId4VcIssuanceSessionRepository } from "@credo-ts/openid4vc/build/openid4vc-issuer/repository"; 23 | import { OfferSessionMetadata } from "./session"; 24 | import { OpenId4VcVerificationSessionState } from "@credo-ts/openid4vc"; 25 | 26 | const zCreateOfferRequest = z.object({ 27 | // FIXME: rename offeredCredentials to credentialSupportedIds in AFJ 28 | credentialSupportedIds: z.array(z.enum(credentialSupportedIds)), 29 | issuerDidMethod: z.string(), 30 | }); 31 | 32 | export const apiRouter = express.Router(); 33 | apiRouter.use(express.json()); 34 | apiRouter.use(express.text()); 35 | apiRouter.post( 36 | "/offers/create", 37 | async (request: Request, response: Response) => { 38 | const issuer = await getIssuer(); 39 | // FIXME: somehow JSON doesn't work 40 | const createOfferRequest = zCreateOfferRequest.parse( 41 | typeof request.body === "string" ? JSON.parse(request.body) : request.body 42 | ); 43 | 44 | const offer = await agent.modules.openId4VcIssuer.createCredentialOffer({ 45 | issuerId: issuer.issuerId, 46 | offeredCredentials: createOfferRequest.credentialSupportedIds, 47 | preAuthorizedCodeFlowConfig: { 48 | userPinRequired: false, 49 | }, 50 | }); 51 | 52 | // FIXME: in 0.5.1 we can pass the issuanceMetadata to the createCredentialOffer method 53 | // directly 54 | const issuanceSessionRepository = agent.dependencyManager.resolve( 55 | OpenId4VcIssuanceSessionRepository 56 | ); 57 | offer.issuanceSession.issuanceMetadata = { 58 | issuerDidMethod: createOfferRequest.issuerDidMethod, 59 | } satisfies OfferSessionMetadata; 60 | await issuanceSessionRepository.update( 61 | agent.context, 62 | offer.issuanceSession 63 | ); 64 | 65 | return response.json(offer); 66 | } 67 | ); 68 | 69 | apiRouter.get("/issuer", async (_, response: Response) => { 70 | const issuer = await getIssuer(); 71 | const availableDids = getAvailableDids(); 72 | 73 | return response.json({ 74 | credentialsSupported: issuer.credentialsSupported, 75 | display: issuer.display, 76 | availableDidMethods: Array.from( 77 | new Set(availableDids.map((did) => `did:${parseDid(did).method}`)) 78 | ), 79 | }); 80 | }); 81 | 82 | const zReceiveOfferRequest = z.object({ 83 | credentialOfferUri: z.string().url(), 84 | }); 85 | 86 | apiRouter.post( 87 | "/offers/receive", 88 | async (request: Request, response: Response) => { 89 | const receiveOfferRequest = zReceiveOfferRequest.parse( 90 | typeof request.body === "string" ? JSON.parse(request.body) : request.body 91 | ); 92 | 93 | const resolvedOffer = 94 | await agent.modules.openId4VcHolder.resolveCredentialOffer( 95 | receiveOfferRequest.credentialOfferUri 96 | ); 97 | const credentials = 98 | await agent.modules.openId4VcHolder.acceptCredentialOfferUsingPreAuthorizedCode( 99 | resolvedOffer, 100 | { 101 | verifyCredentialStatus: false, 102 | credentialBindingResolver: async ({ 103 | keyType, 104 | supportsJwk, 105 | supportedDidMethods, 106 | }) => { 107 | if (supportedDidMethods?.includes("did:key")) { 108 | const didKeyResult = await agent.dids.create( 109 | { 110 | method: "key", 111 | options: { 112 | keyType, 113 | }, 114 | } 115 | ); 116 | 117 | if (didKeyResult.didState.state !== "finished") { 118 | throw new Error("did creation failed"); 119 | } 120 | const firstVerificationMethod = 121 | didKeyResult.didState.didDocument.verificationMethod?.[0]; 122 | if (!firstVerificationMethod) { 123 | throw new Error("did document has no verification method"); 124 | } 125 | 126 | return { 127 | method: "did", 128 | didUrl: firstVerificationMethod.id, 129 | }; 130 | } 131 | 132 | if (supportsJwk) { 133 | const key = await agent.wallet.createKey({ 134 | keyType, 135 | }); 136 | 137 | return { 138 | method: "jwk", 139 | jwk: getJwkFromKey(key), 140 | }; 141 | } 142 | 143 | throw new Error( 144 | "only did:key and jwk are supported for holder binding" 145 | ); 146 | }, 147 | } 148 | ); 149 | 150 | return response.json({ 151 | credentials: credentials.map((credential) => { 152 | if (credential instanceof W3cJsonLdVerifiableCredential) { 153 | return { 154 | pretty: credential.toJson(), 155 | encoded: credential.toJson(), 156 | }; 157 | } 158 | 159 | if (credential instanceof W3cJwtVerifiableCredential) { 160 | return { 161 | pretty: JsonTransformer.toJSON(credential.credential), 162 | encoded: credential.serializedJwt, 163 | }; 164 | } 165 | 166 | return { 167 | pretty: { 168 | ...credential, 169 | compact: undefined, 170 | }, 171 | encoded: credential.compact, 172 | }; 173 | }), 174 | }); 175 | } 176 | ); 177 | 178 | const zCreatePresentationRequestBody = z.object({ 179 | presentationDefinition: z.record(z.string(), z.any()), 180 | }); 181 | 182 | apiRouter.post( 183 | "/requests/create", 184 | async (request: Request, response: Response) => { 185 | const verifier = await getVerifier(); 186 | 187 | // FIXME: somehow JSON doesn't work 188 | const createPresentationRequestBody = zCreatePresentationRequestBody.parse( 189 | typeof request.body === "string" ? JSON.parse(request.body) : request.body 190 | ); 191 | 192 | // DIIPv1 uses ES256/P256 keys so we use that to create the request 193 | const webDid = await getWebDidDocument(); 194 | const verificationMethods = webDid.verificationMethod ?? []; 195 | const keys = verificationMethods.map((v) => 196 | getKeyFromVerificationMethod(v) 197 | ); 198 | const verificationMethodIndex = keys.findIndex( 199 | (k) => k.keyType === KeyType.P256 200 | ); 201 | 202 | if (verificationMethodIndex === -1) { 203 | return response.status(500).json({ 204 | error: "No P-256 verification method found", 205 | }); 206 | } 207 | 208 | const { authorizationRequest, verificationSession } = 209 | await agent.modules.openId4VcVerifier.createAuthorizationRequest({ 210 | verifierId: verifier.verifierId, 211 | requestSigner: { 212 | didUrl: verificationMethods[verificationMethodIndex].id, 213 | method: "did", 214 | }, 215 | presentationExchange: { 216 | definition: 217 | createPresentationRequestBody.presentationDefinition as any, 218 | }, 219 | }); 220 | 221 | return response.json({ 222 | authorizationRequestUri: authorizationRequest, 223 | verificationSessionId: verificationSession.id, 224 | }); 225 | } 226 | ); 227 | 228 | const zReceivePresentationRequestBody = z.object({ 229 | authorizationRequestUri: z.string().url(), 230 | }); 231 | 232 | apiRouter.get("/requests/:verificationSessionId", async (request, response) => { 233 | const verificationSessionId = request.params.verificationSessionId; 234 | 235 | try { 236 | const verificationSession = 237 | await agent.modules.openId4VcVerifier.getVerificationSessionById( 238 | verificationSessionId 239 | ); 240 | 241 | if ( 242 | verificationSession.state === 243 | OpenId4VcVerificationSessionState.ResponseVerified 244 | ) { 245 | const verified = 246 | await agent.modules.openId4VcVerifier.getVerifiedAuthorizationResponse( 247 | verificationSessionId 248 | ); 249 | 250 | return response.json({ 251 | verificationSessionId: verificationSession.id, 252 | responseStatus: verificationSession.state, 253 | error: verificationSession.errorMessage, 254 | 255 | presentations: verified.presentationExchange?.presentations.map( 256 | (presentation) => { 257 | if (presentation instanceof W3cJsonLdVerifiablePresentation) { 258 | return { 259 | pretty: presentation.toJson(), 260 | encoded: presentation.toJson(), 261 | }; 262 | } 263 | 264 | if (presentation instanceof W3cJwtVerifiablePresentation) { 265 | return { 266 | pretty: JsonTransformer.toJSON(presentation.presentation), 267 | encoded: presentation.serializedJwt, 268 | }; 269 | } 270 | 271 | return { 272 | pretty: { 273 | ...presentation, 274 | compact: undefined, 275 | }, 276 | encoded: presentation.compact, 277 | }; 278 | } 279 | ), 280 | submission: verified.presentationExchange?.submission, 281 | definition: verified.presentationExchange?.definition, 282 | }); 283 | } 284 | 285 | return response.json({ 286 | verificationSessionId: verificationSession.id, 287 | responseStatus: verificationSession.state, 288 | error: verificationSession.errorMessage, 289 | }); 290 | } catch (error) { 291 | if (error instanceof RecordNotFoundError) { 292 | return response.status(404).send("Verification session not found"); 293 | } 294 | } 295 | }); 296 | 297 | apiRouter.post( 298 | "/requests/receive", 299 | async (request: Request, response: Response) => { 300 | const receivePresentationRequestBody = 301 | zReceivePresentationRequestBody.parse( 302 | typeof request.body === "string" 303 | ? JSON.parse(request.body) 304 | : request.body 305 | ); 306 | 307 | const resolved = 308 | await agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest( 309 | receivePresentationRequestBody.authorizationRequestUri 310 | ); 311 | 312 | if (!resolved.presentationExchange) { 313 | return response.status(500).json({ 314 | error: 315 | "Expected presentation_definition to be included in authorization request", 316 | }); 317 | } 318 | 319 | // FIXME: expose PresentationExchange API (or allow auto-select in another way) 320 | const presentationExchangeService = agent.dependencyManager.resolve( 321 | DifPresentationExchangeService 322 | ); 323 | 324 | const selectedCredentials = 325 | presentationExchangeService.selectCredentialsForRequest( 326 | resolved.presentationExchange?.credentialsForRequest 327 | ); 328 | 329 | const { submittedResponse, serverResponse } = 330 | await agent.modules.openId4VcHolder.acceptSiopAuthorizationRequest({ 331 | authorizationRequest: resolved.authorizationRequest, 332 | presentationExchange: { 333 | credentials: selectedCredentials, 334 | }, 335 | }); 336 | 337 | return response.status(serverResponse.status).json(submittedResponse); 338 | } 339 | ); 340 | 341 | apiRouter.use( 342 | (error: Error, request: Request, response: Response, next: NextFunction) => { 343 | console.error("Unhandled error", error); 344 | return response.status(500).json({ 345 | error: error.message, 346 | }); 347 | } 348 | ); 349 | -------------------------------------------------------------------------------- /agent/src/issuer.ts: -------------------------------------------------------------------------------- 1 | import { OpenId4VciCredentialRequestToCredentialMapper } from "@credo-ts/openid4vc"; 2 | import { 3 | W3cCredential, 4 | parseDid, 5 | KeyType, 6 | getKeyFromVerificationMethod, 7 | } from "@credo-ts/core"; 8 | import { agent } from "./agent"; 9 | import { 10 | animoOpenId4VcPlaygroundCredentialJwtVc, 11 | animoOpenId4VcPlaygroundCredentialLdpVc, 12 | animoOpenId4VcPlaygroundCredentialSdJwtVcDid, 13 | animoOpenId4VcPlaygroundCredentialSdJwtVcJwk, 14 | credentialsSupported, 15 | issuerDisplay, 16 | } from "./issuerMetadata"; 17 | import { OfferSessionMetadata } from "./session"; 18 | import { getAvailableDids } from "./did"; 19 | 20 | const issuerId = "e451c49f-1186-4fe4-816d-a942151dfd59"; 21 | 22 | export async function createIssuer() { 23 | return agent.modules.openId4VcIssuer.createIssuer({ 24 | issuerId, 25 | credentialsSupported, 26 | display: issuerDisplay, 27 | }); 28 | } 29 | 30 | export async function doesIssuerExist() { 31 | try { 32 | await agent.modules.openId4VcIssuer.getByIssuerId(issuerId); 33 | return true; 34 | } catch (error) { 35 | return false; 36 | } 37 | } 38 | 39 | export async function getIssuer() { 40 | return agent.modules.openId4VcIssuer.getByIssuerId(issuerId); 41 | } 42 | 43 | export async function updateIssuer() { 44 | await agent.modules.openId4VcIssuer.updateIssuerMetadata({ 45 | issuerId, 46 | credentialsSupported, 47 | display: issuerDisplay, 48 | }); 49 | } 50 | 51 | export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToCredentialMapper = 52 | async ({ 53 | issuanceSession, 54 | credentialsSupported, 55 | // FIXME: it would be useful if holderBinding would include some metadata on the key type / alg used 56 | // for the key binding 57 | holderBinding, 58 | }) => { 59 | const credentialSupported = credentialsSupported[0]; 60 | 61 | // not sure if this check is needed anymore 62 | if (!issuanceSession) throw new Error("Issuance session not found"); 63 | if (!issuanceSession.issuanceMetadata) 64 | throw new Error("No issuance metadata"); 65 | 66 | const { issuerDidMethod } = 67 | issuanceSession.issuanceMetadata as unknown as OfferSessionMetadata; 68 | 69 | const possibleDids = getAvailableDids().filter((d) => 70 | d.startsWith(issuerDidMethod) 71 | ); 72 | 73 | let holderKeyType: KeyType; 74 | if (holderBinding.method === "jwk") { 75 | holderKeyType = holderBinding.jwk.keyType; 76 | } else { 77 | const holderDidDocument = await agent.dids.resolveDidDocument( 78 | holderBinding.didUrl 79 | ); 80 | const verificationMethod = holderDidDocument.dereferenceKey( 81 | holderBinding.didUrl 82 | ); 83 | holderKeyType = getKeyFromVerificationMethod(verificationMethod).keyType; 84 | } 85 | 86 | if (possibleDids.length === 0) { 87 | throw new Error("No available DIDs for the issuer method"); 88 | } 89 | 90 | let issuerDidUrl: string | undefined = undefined; 91 | 92 | for (const possibleDid of possibleDids) { 93 | const didDocument = await agent.dids.resolveDidDocument(possibleDid); 94 | // Set the first verificationMethod as backup, in case we won't find a match 95 | if (!issuerDidUrl && didDocument.verificationMethod?.[0].id) { 96 | issuerDidUrl = didDocument.verificationMethod?.[0].id; 97 | } 98 | 99 | const matchingVerificationMethod = didDocument.assertionMethod?.find( 100 | (assertionMethod) => { 101 | const verificationMethod = 102 | typeof assertionMethod === "string" 103 | ? didDocument.dereferenceVerificationMethod(assertionMethod) 104 | : assertionMethod; 105 | const keyType = 106 | getKeyFromVerificationMethod(verificationMethod).keyType; 107 | return keyType === holderKeyType; 108 | } 109 | ); 110 | 111 | if (matchingVerificationMethod) { 112 | issuerDidUrl = 113 | typeof matchingVerificationMethod === "string" 114 | ? matchingVerificationMethod 115 | : matchingVerificationMethod.id; 116 | break; 117 | } 118 | } 119 | 120 | if (!issuerDidUrl) { 121 | throw new Error("No matching verification method found"); 122 | } 123 | 124 | if ( 125 | credentialSupported.format === "vc+sd-jwt" && 126 | (credentialSupported.id === 127 | animoOpenId4VcPlaygroundCredentialSdJwtVcDid.id || 128 | credentialSupported.id === 129 | animoOpenId4VcPlaygroundCredentialSdJwtVcJwk.id) 130 | ) { 131 | return { 132 | format: "vc+sd-jwt", 133 | holder: holderBinding, 134 | credentialSupportedId: credentialSupported.id, 135 | payload: { 136 | vct: credentialSupported.vct, 137 | 138 | playground: { 139 | framework: "Aries Framework JavaScript", 140 | language: "TypeScript", 141 | version: "1.0", 142 | createdBy: "Animo Solutions", 143 | }, 144 | }, 145 | issuer: { 146 | didUrl: issuerDidUrl, 147 | method: "did", 148 | }, 149 | disclosureFrame: { 150 | playground: { 151 | language: true, 152 | version: true, 153 | }, 154 | } as any, 155 | }; 156 | } 157 | 158 | if ( 159 | (credentialSupported.format === "jwt_vc_json" && 160 | credentialSupported.id === 161 | animoOpenId4VcPlaygroundCredentialJwtVc.id) || 162 | (credentialSupported.format === "ldp_vc" && 163 | credentialSupported.id === animoOpenId4VcPlaygroundCredentialLdpVc.id) 164 | ) { 165 | if (holderBinding.method !== "did") { 166 | throw new Error("Only did holder binding supported for JWT VC"); 167 | } 168 | return { 169 | format: 170 | credentialSupported.format === "jwt_vc_json" ? "jwt_vc" : "ldp_vc", 171 | verificationMethod: issuerDidUrl, 172 | credentialSupportedId: credentialSupported.id, 173 | credential: W3cCredential.fromJson({ 174 | // FIXME: we need to include/cache default contexts in AFJ 175 | // It quite slow the first time now 176 | // And not secure 177 | "@context": 178 | credentialSupported.format === "ldp_vc" 179 | ? [ 180 | "https://www.w3.org/2018/credentials/v1", 181 | // Fields must be defined for JSON-LD 182 | { 183 | "@vocab": 184 | "https://www.w3.org/ns/credentials/issuer-dependent#", 185 | }, 186 | ] 187 | : ["https://www.w3.org/2018/credentials/v1"], 188 | // TODO: should 'VerifiableCredential' be in the issuer metadata type? 189 | // FIXME: jwt verification did not fail when this was array within array 190 | // W3cCredential is not validated in AFJ??? 191 | type: ["VerifiableCredential", ...credentialSupported.types], 192 | issuanceDate: new Date().toISOString(), 193 | issuer: parseDid(issuerDidUrl).did, 194 | credentialSubject: { 195 | id: parseDid(holderBinding.didUrl).did, 196 | playground: { 197 | framework: "Aries Framework JavaScript", 198 | language: "TypeScript", 199 | version: "1.0", 200 | createdBy: "Animo Solutions", 201 | }, 202 | }, 203 | }), 204 | }; 205 | } 206 | 207 | throw new Error(`Unsupported credential ${credentialSupported.id}`); 208 | }; 209 | -------------------------------------------------------------------------------- /agent/src/issuerMetadata.ts: -------------------------------------------------------------------------------- 1 | import { JwaSignatureAlgorithm } from "@credo-ts/core"; 2 | import { 3 | OpenId4VciCredentialSupportedWithId, 4 | OpenId4VciCredentialFormatProfile, 5 | OpenId4VciIssuerMetadataDisplay, 6 | } from "@credo-ts/openid4vc"; 7 | 8 | const ANIMO_BLUE = "#5e7db6"; 9 | const ANIMO_RED = "#E17471"; 10 | const ANIMO_DARK_BACKGROUND = "#202223"; 11 | const WHITE = "#FFFFFF"; 12 | 13 | export const issuerDisplay = [ 14 | { 15 | background_color: ANIMO_DARK_BACKGROUND, 16 | name: "Animo OpenID4VC Playground", 17 | locale: "en", 18 | logo: { 19 | alt_text: "Animo logo", 20 | url: "https://i.imgur.com/PUAIUed.jpeg", 21 | }, 22 | text_color: WHITE, 23 | }, 24 | ] satisfies OpenId4VciIssuerMetadataDisplay[]; 25 | 26 | export const animoOpenId4VcPlaygroundCredentialSdJwtVcDid = { 27 | id: "AnimoOpenId4VcPlaygroundSdJwtVcDid", 28 | format: OpenId4VciCredentialFormatProfile.SdJwtVc, 29 | vct: "AnimoOpenId4VcPlayground", 30 | cryptographic_binding_methods_supported: ["did:key", "did:jwk"], 31 | cryptographic_suites_supported: [ 32 | JwaSignatureAlgorithm.EdDSA, 33 | JwaSignatureAlgorithm.ES256, 34 | ], 35 | display: [ 36 | { 37 | name: "SD-JWT-VC", 38 | description: "DID holder binding", 39 | background_color: ANIMO_DARK_BACKGROUND, 40 | locale: "en", 41 | text_color: WHITE, 42 | }, 43 | ], 44 | } as const satisfies OpenId4VciCredentialSupportedWithId; 45 | 46 | export const animoOpenId4VcPlaygroundCredentialSdJwtVcJwk = { 47 | id: "AnimoOpenId4VcPlaygroundSdJwtVcJwk", 48 | format: OpenId4VciCredentialFormatProfile.SdJwtVc, 49 | vct: "AnimoOpenId4VcPlayground", 50 | cryptographic_binding_methods_supported: ["jwk"], 51 | cryptographic_suites_supported: [ 52 | JwaSignatureAlgorithm.EdDSA, 53 | JwaSignatureAlgorithm.ES256, 54 | ], 55 | display: [ 56 | { 57 | name: "SD-JWT-VC", 58 | description: "JWK holder binding", 59 | background_color: ANIMO_DARK_BACKGROUND, 60 | locale: "en", 61 | text_color: WHITE, 62 | }, 63 | ], 64 | } as const satisfies OpenId4VciCredentialSupportedWithId; 65 | 66 | export const animoOpenId4VcPlaygroundCredentialJwtVc = { 67 | id: "AnimoOpenId4VcPlaygroundJwtVc", 68 | format: OpenId4VciCredentialFormatProfile.JwtVcJson, 69 | types: ["AnimoOpenId4VcPlayground"], 70 | cryptographic_binding_methods_supported: ["did:key", "did:jwk"], 71 | cryptographic_suites_supported: [ 72 | JwaSignatureAlgorithm.EdDSA, 73 | JwaSignatureAlgorithm.ES256, 74 | ], 75 | display: [ 76 | { 77 | name: "JWT VC", 78 | background_color: ANIMO_DARK_BACKGROUND, 79 | locale: "en", 80 | text_color: WHITE, 81 | }, 82 | ], 83 | } as const satisfies OpenId4VciCredentialSupportedWithId; 84 | 85 | export const animoOpenId4VcPlaygroundCredentialLdpVc = { 86 | id: "AnimoOpenId4VcPlaygroundLdpVc", 87 | format: OpenId4VciCredentialFormatProfile.LdpVc, 88 | types: ["AnimoOpenId4VcPlayground"], 89 | "@context": ["https://www.w3.org/2018/credentials/v1"], 90 | cryptographic_binding_methods_supported: ["did:key", "did:jwk"], 91 | cryptographic_suites_supported: [ 92 | JwaSignatureAlgorithm.EdDSA, 93 | // TODO: is it needed that proof type is added here? 94 | // According to spec yes, but it's only used for the request proof, 95 | // which is a jwt/cwt (so alg) 96 | "Ed25519Signature2018", 97 | ], 98 | display: [ 99 | { 100 | name: "LDP VC", 101 | background_color: ANIMO_DARK_BACKGROUND, 102 | locale: "en", 103 | text_color: WHITE, 104 | }, 105 | ], 106 | } as const satisfies OpenId4VciCredentialSupportedWithId; 107 | 108 | export const credentialsSupported = [ 109 | animoOpenId4VcPlaygroundCredentialSdJwtVcDid, 110 | animoOpenId4VcPlaygroundCredentialSdJwtVcJwk, 111 | animoOpenId4VcPlaygroundCredentialJwtVc, 112 | // Not really working yet 113 | // FIXME: Ed25519Signature2018 required ed25519 context url 114 | // but that is bullshit, as you can just use another verification 115 | // method to issue/verify such as JsonWebKey or MultiKey 116 | // animoOpenId4VcPlaygroundCredentialLdpVc, 117 | ] as const satisfies OpenId4VciCredentialSupportedWithId[]; 118 | type CredentialSupportedId = (typeof credentialsSupported)[number]["id"]; 119 | export const credentialSupportedIds = credentialsSupported.map((s) => s.id) as [ 120 | CredentialSupportedId, 121 | ...CredentialSupportedId[] 122 | ]; 123 | -------------------------------------------------------------------------------- /agent/src/server.ts: -------------------------------------------------------------------------------- 1 | import { agent, openId4VciRouter, openId4VpRouter } from "./agent"; 2 | import { apiRouter } from "./endpoints"; 3 | import { createIssuer, doesIssuerExist, updateIssuer } from "./issuer"; 4 | import { createVerifier, doesVerifierExist } from "./verifier"; 5 | import express, { Response } from "express"; 6 | import { getWebDidDocument, setupAllDids } from "./did"; 7 | import cors from "cors"; 8 | 9 | async function run() { 10 | await agent.initialize(); 11 | 12 | if (!(await doesIssuerExist())) { 13 | await createIssuer(); 14 | } else { 15 | // We update the issuer metadata on every startup to sync the static issuer metadata with the issuer metadata record 16 | await updateIssuer(); 17 | } 18 | 19 | if (!(await doesVerifierExist())) { 20 | await createVerifier(); 21 | } 22 | 23 | await setupAllDids(); 24 | 25 | const app = express(); 26 | app.use(cors({ origin: "*" })); 27 | 28 | app.use("/oid4vci", openId4VciRouter); 29 | app.use("/siop", openId4VpRouter); 30 | app.use("/api", apiRouter); 31 | app.use("/.well-known/did.json", async (_, response: Response) => { 32 | const didWeb = await getWebDidDocument(); 33 | return response.json(didWeb.toJSON()); 34 | }); 35 | 36 | app.listen(3001, () => 37 | agent.config.logger.info("app listening on port 3001") 38 | ); 39 | 40 | // @ts-ignore 41 | app.use((err, _, res, __) => { 42 | console.error(err.stack); 43 | res.status(500).send("Something broke!"); 44 | }); 45 | } 46 | 47 | run(); 48 | -------------------------------------------------------------------------------- /agent/src/session.ts: -------------------------------------------------------------------------------- 1 | export interface OfferSessionMetadata { 2 | issuerDidMethod: string; 3 | } 4 | -------------------------------------------------------------------------------- /agent/src/verifier.ts: -------------------------------------------------------------------------------- 1 | import { agent } from "./agent"; 2 | 3 | const verifierId = "c01ea0f3-34df-41d5-89d1-50ef3d181855"; 4 | 5 | export async function createVerifier() { 6 | return agent.modules.openId4VcVerifier.createVerifier({ 7 | verifierId, 8 | }); 9 | } 10 | 11 | export async function doesVerifierExist() { 12 | try { 13 | await agent.modules.openId4VcVerifier.getVerifierByVerifierId(verifierId); 14 | return true; 15 | } catch (error) { 16 | return false; 17 | } 18 | } 19 | 20 | export async function getVerifier() { 21 | return agent.modules.openId4VcVerifier.getVerifierByVerifierId(verifierId); 22 | } 23 | -------------------------------------------------------------------------------- /agent/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "outDir": "dist", 5 | "module": "commonjs", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://localhost:3001 -------------------------------------------------------------------------------- /app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | COPY ./out /usr/share/nginx/html 4 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 5 | 6 | EXPOSE 80 -------------------------------------------------------------------------------- /app/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | body { 12 | color: rgb(var(--foreground-rgb)); 13 | background: linear-gradient( 14 | to bottom, 15 | transparent, 16 | rgb(var(--background-end-rgb)) 17 | ) 18 | rgb(var(--background-start-rgb)); 19 | } 20 | -------------------------------------------------------------------------------- /app/app/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "OpenID4VC Playground", 9 | description: "By Animo", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Main } from "@/components/main"; 2 | 3 | export default function Home() { 4 | return
; 5 | } 6 | -------------------------------------------------------------------------------- /app/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": false 11 | }, 12 | "aliases": { 13 | "utils": "@/lib/utils", 14 | "components": "@/components" 15 | } 16 | } -------------------------------------------------------------------------------- /app/components/IssueTab.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 2 | import { Label } from "@/components/ui/label"; 3 | import { 4 | SelectValue, 5 | SelectTrigger, 6 | SelectItem, 7 | SelectGroup, 8 | SelectContent, 9 | Select, 10 | } from "@/components/ui/select"; 11 | import { 12 | TooltipProvider, 13 | Tooltip, 14 | TooltipTrigger, 15 | TooltipContent, 16 | } from "@/components/ui/tooltip"; 17 | import QRCode from "react-qr-code"; 18 | import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; 19 | import { Button } from "@/components/ui/button"; 20 | import { Card } from "@/components/ui/card"; 21 | import { FormEvent, useEffect, useState } from "react"; 22 | import { getIssuer, createOffer } from "../lib/api"; 23 | 24 | export function IssueTab() { 25 | const [credentialType, setCredentialType] = useState(); 26 | const [issuerDid, setIssuerDid] = useState(); 27 | const [credentialOfferUri, setCredentialOfferUri] = useState(); 28 | const [issuer, setIssuer] = useState<{ 29 | credentialsSupported: Array<{ 30 | id: string; 31 | display: [{ name: string; description?: string }]; 32 | }>; 33 | availableDidMethods: string[]; 34 | display: {}; 35 | }>(); 36 | 37 | useEffect(() => { 38 | getIssuer().then(setIssuer); 39 | }, []); 40 | async function onSubmitIssueCredential(e: FormEvent) { 41 | e.preventDefault(); 42 | const _issuerDidMethod = issuerDid ?? issuer?.availableDidMethods[0]; 43 | const _credentialType = 44 | credentialType ?? issuer?.credentialsSupported[0].id; 45 | if (!_issuerDidMethod || !_credentialType) { 46 | throw new Error("No issuer or credential type"); 47 | } 48 | 49 | const offer = await createOffer({ 50 | credentialSupportedId: _credentialType, 51 | issuerDidMethod: _issuerDidMethod, 52 | }); 53 | setCredentialOfferUri(offer.credentialOffer); 54 | } 55 | 56 | return ( 57 | 58 |
59 |
60 | 61 | 86 |
87 |
88 | 89 | 112 |
113 |
114 | {credentialOfferUri ? ( 115 | 116 | 117 |
118 |
119 | 120 |
121 | 122 |

124 | navigator.clipboard.writeText(e.currentTarget.innerText) 125 | } 126 | className="text-gray-500 break-all cursor-pointer" 127 | > 128 | {credentialOfferUri} 129 |

130 |
131 |
132 | 133 | 134 |

Click to copy

135 |
136 |
137 |
138 | ) : ( 139 |

140 | Credential offer will be displayed here 141 |

142 | )} 143 |
144 | 151 |
152 | 153 | 154 | Warning 155 | 156 | When using the{" "} 157 | 158 | Paradym Wallet 159 | 160 | , only issuance of JWT credentials (not SD-JWT credentials) using a 161 | did method other than did:cheqd is supported. 162 | 163 | 164 |
165 | ); 166 | } 167 | -------------------------------------------------------------------------------- /app/components/ReceiveTab.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from "react"; 2 | import { receiveOffer } from "../lib/api"; 3 | import { Label } from "@/components/ui/label"; 4 | import { HighLight } from "@/components/highLight"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Card } from "@/components/ui/card"; 7 | 8 | export function ReceiveTab() { 9 | const [receiveCredentialOfferUri, setReceiveCredentialOfferUri] = 10 | useState(); 11 | const [receivedCredentials, setReceivedCredentials] = useState(); 12 | 13 | async function onSubmitReceiveOffer(e: FormEvent) { 14 | e.preventDefault(); 15 | if (!receiveCredentialOfferUri) return; 16 | 17 | setReceivedCredentials(await receiveOffer(receiveCredentialOfferUri)); 18 | } 19 | 20 | return ( 21 | 22 |
23 |
24 | 25 |