diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7bf452f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +build +.* +secret* +data diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1327eff --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +gradlew text eol=lf \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c91aa9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI/CD Workflow for Walt.ID Wallet Kit + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + verify-wrapper: + name: "Verification" + runs-on: "ubuntu-latest" + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Validate gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + gradle: + needs: verify-wrapper + name: "Build" + strategy: + matrix: + # os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup java + uses: actions/setup-java@v2.1.0 + with: + distribution: 'adopt-hotspot' + java-version: '16' + - name: Running gradle build + uses: eskatos/gradle-command-action@v1.3.3 + with: + arguments: build --no-daemon diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000..60b9443 --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,55 @@ +name: Snapshot release workflow for Walt.ID Wallet Kit + +on: + push: + branches: + - master + +jobs: + verify-wrapper: + name: "Verification" + runs-on: "ubuntu-latest" + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Validate gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + gradle: + needs: verify-wrapper + name: "Build" + strategy: + matrix: + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup java + uses: actions/setup-java@v2.1.0 + with: + distribution: 'adopt-hotspot' + java-version: '16' + - name: Running gradle build + uses: eskatos/gradle-command-action@v1.3.3 + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + with: + # arguments: build publish --no-daemon + arguments: build --no-daemon + - name: Docker Build and Push SNAPSHOT + uses: philpotisk/github-action-docker-build-push@master + env: + DOCKER_USERNAME: ${{secrets.DOCKER_USERNAME}} + DOCKER_PASSWORD: ${{secrets.DOCKER_PASSWORD}} + DOCKER_FILE: Dockerfile + CONTAINER_TAG: waltid/walletkit:latest + - name: Prepare CD + run: sed "s/_DEFAULT_DEPLOYMENT_/$GITHUB_SHA/g" k8s/deployment-dev.yaml > k8s/deployment_mod.yaml + - name: Continuous deployment + uses: actions-hub/kubectl@master + env: + KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} + with: + args: apply -n dev -f k8s/deployment_mod.yaml diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..e379a89 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,42 @@ +name: Snapshot release workflow for Walt.ID Wallet Kit + +on: + push: + tags: + - 'v*' + +jobs: + verify-wrapper: + name: "Verification" + runs-on: "ubuntu-latest" + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Validate gradle wrapper + uses: gradle/wrapper-validation-action@v1 + + gradle: + needs: verify-wrapper + name: "Build" + strategy: + matrix: + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Docker Build and Push SNAPSHOT + uses: philpotisk/github-action-docker-build-push@master + env: + DOCKER_USERNAME: ${{secrets.DOCKER_USERNAME}} + DOCKER_PASSWORD: ${{secrets.DOCKER_PASSWORD}} + DOCKER_FILE: Dockerfile + CONTAINER_TAG: ${{ format('waltid/walletkit:{0}', github.ref_name) }} + - name: Prepare CD + run: sed "s/_DEFAULT_DEPLOYMENT_/$GITHUB_SHA/g; s/_VERSION_TAG_/$GITHUB_REF_NAME/g" k8s/deployment-prod.yaml > k8s/deployment_mod.yaml + - name: Continuous deployment + uses: actions-hub/kubectl@master + env: + KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }} + with: + args: apply -n default -f k8s/deployment_mod.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acd0884 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Intellij +.idea/ +*.iml +*.iws +out +data + +# Mac +.DS_Store + +# Maven +log/ +target/ +*.log + +# Gradle +.gradle +build/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache +**/build/ +docker/data +docker/data_ +issuers-secret.json +/secret_maven_password.txt +/secret_maven_username.txt + +# Env +.*.env +.env \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fcbc0a4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +Notable changes since the last release of the [walt.id Wallet - Kit](https://github.com/walt-id/waltid-walletkit/). + +## [Unreleased] +- https://github.com/walt-id/waltid-walletkit/issues/73 +- https://github.com/walt-id/waltid-walletkit/issues/74 +- https://github.com/walt-id/waltid-walletkit/issues/78 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1e6d5a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +### Configuration + +# set --build-args SKIP_TESTS=true to use +ARG SKIP_TESTS + +# --- dos2unix-env # convert line endings from Windows machines +FROM docker.io/rkimf1/dos2unix@sha256:60f78cd8bf42641afdeae3f947190f98ae293994c0443741a2b3f3034998a6ed as dos2unix-env +WORKDIR /convert +COPY gradlew . +RUN dos2unix ./gradlew + +# --- build-env +FROM docker.io/gradle:7.6-jdk as build-env + +ARG SKIP_TESTS + +WORKDIR /appbuild + +COPY . /appbuild + +# copy converted Windows line endings files +COPY --from=dos2unix-env /convert/gradlew . + +# cache Gradle dependencies +VOLUME /home/gradle/.gradle + +RUN if [ -z "$SKIP_TESTS" ]; \ + then echo "* Running full build" && gradle -i clean build installDist; \ + else echo "* Building but skipping tests" && gradle -i clean installDist -x test; \ + fi + +# --- opa-env +FROM docker.io/openpolicyagent/opa:0.46.1-static as opa-env + +# --- iota-env +FROM docker.io/waltid/waltid_iota_identity_wrapper:latest as iota-env + +# --- app-env +FROM docker.io/eclipse-temurin:19 AS app-env + +WORKDIR /app + +COPY --from=opa-env /opa /usr/local/bin/opa + +COPY --from=iota-env /usr/local/lib/libwaltid_iota_identity_wrapper.so /usr/local/lib/libwaltid_iota_identity_wrapper.so +RUN ldconfig + +COPY --from=build-env /appbuild/build/install/waltid-walletkit /app/ +COPY --from=build-env /appbuild/service-matrix.properties /app/ +COPY --from=build-env /appbuild/config /app/config + + +### Execution +EXPOSE 7000 7001 7002 7003 7004 7010 + +ENTRYPOINT ["/app/bin/waltid-walletkit"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b71b972 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +
+

Wallet Kit

+ by walt.id +

Supercharge your app with SSI, NFTs or fungible tokens

+ +[![CI/CD Workflow for Walt.ID Wallet Kit](https://github.com/walt-id/waltid-walletkit/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/walt-id/waltid-walletkit/actions/workflows/ci.yml) + + +Join community! + + +Follow @walt_id + + +

+ +## Getting Started + +- [REST Api](https://docs.walt.id/v/web-wallet/getting-started/rest-apis) - Use the functionality of the Wallet Kit via an REST api. +- [Maven/Gradle Dependency](https://docs.walt.id/v/web-wallet/getting-started/dependency-jvm) - Use the functions of the Wallet Kit in a Kotlin/Java project. + +The Wallet Kit on its own gives you, the backend infrastructure to build a custom wallet solution. However, in conjunction with our pre-build frontend components, +you can even have a full solution. Get started with the full solution, using: +- [Docker Compose](https://docs.walt.id/v/web-wallet/getting-started/local-build/docker-build/docker-compose#docker-compose) +- [Local Docker Build](https://docs.walt.id/v/web-wallet/getting-started/local-build#docker-build) +- [Local Build](https://docs.walt.id/v/web-wallet/getting-started/local-build/local-build) + +Checkout the [Official Documentation](https://docs.walt.id/v/web-wallet/wallet-kit/readme), to find out more. + +## What is the Wallet Kit? + +It is the API and backend business logic for the walt.id web wallet. +Additionally, it includes a reference implementation of a Verifier and Issuer Portal backend. + + +## Services +### Web walletkit +* **User management** + * Authorization is currently mocked and not production ready + * User-context switching and user-specific encapsulated data storage +* **Basic user data management** + * List dids + * List credentials +* **Verifiable Credential and Presentation exchange** + * Support for credential presentation exchange based on OIDC-SIOPv2 spec + +### Verifier portal backend +* **Wallet configuration** + * Possibility to configure list of supported wallets (defaults to walt.id web wallet) +* **Presentation exchange** + * Support for presentation exchange based on OIDC-SIOPv2 spec + +### Issuer portal backend +* **Wallet configuration** + * Possibility to configure list of supported wallets (defaults to walt.id web wallet) +* **Verifiable credential issuance** + * Support for issuing verifiable credentials to the web wallet, based on OIDC-SIOPv2 spec + +## Join the community + +* Connect and get the latest updates: [Discord](https://discord.gg/AW8AgqJthZ) | [Newsletter](https://walt.id/newsletter) | [YouTube](https://www.youtube.com/channel/UCXfOzrv3PIvmur_CmwwmdLA) | [Twitter](https://mobile.twitter.com/walt_id) +* Get help, request features and report bugs: [GitHub Discussions](https://github.com/walt-id/.github/discussions) + + +## Related components | Full Solution +* [Web Wallet](https://github.com/walt-id/waltid-web-wallet) - The frontend solution for holders +* [Verifier Portal](https://github.com/walt-id/waltid-verifier-portal) - The frontend solution for verifiers +* [Issuer Portal](https://github.com/walt-id/waltid-issuer-portal) - The frontend solution for issuers + +## Test deployment + +The snap-shot version of this repository is automatically deployed for testing purpose. Feel free to access the test system at the following endpoints: + +* https://issuer.walt.id +* https://wallet.walt.id +* https://verifier.walt.id + +## Usage + +Configuration and data are kept in sub folders of the data root: +* `config/` +* `data/` + +Data root is by default the current **working directory**. + +It can be overridden by specifying the **environment variable**: + +`WALTID_DATA_ROOT` + +### Verifier portal and wallet configuration: + +**config/verifier-config.json** + +``` +{ + "verifierUiUrl": "http://localhost:4000", # URL of verifier portal UI + "verifierApiUrl": "http://localhost:8080/verifier-api", # URL of verifier portal API + "wallets": { # wallet configuration + "walt.id": { # wallet configuration key + "id": "walt.id", # wallet ID + "url": "http://localhost:3000", # URL of wallet UI + "presentPath": "CredentialRequest", # URL subpath for a credential presentation request + "receivePath" : "ReceiveCredential/", # URL subpath for a credential issuance request + "description": "walt.id web wallet" # Wallet description + } + } +} +``` + +### Issuer portal and wallet configuration: + +**config/issuer-config.json** + +``` +{ + "issuerUiUrl": "http://localhost:5000", # URL of issuer portal UI + "issuerApiUrl": "http://localhost:8080/issuer-api", # URL of issuer portal API (needs to be accessible from the walletkit) + "wallets": { # wallet configuration + "walt.id": { # wallet configuration key + "id": "walt.id", # wallet ID + "url": "http://localhost:3000", # URL of wallet UI + "presentPath": "CredentialRequest", # URL subpath for a credential presentation request + "receivePath" : "ReceiveCredential/", # URL subpath for a credential issuance request + "description": "walt.id web wallet" # Wallet description + } + } +} +``` + +### Wallet backend configuration + +User data (dids, keys, credentials) are currently stored under + +`data/` + +It is planned to allow users to define their own storage preferences, in the future. + +### APIs + +The APIs are launched on port 8080. + +A **swagger documentation** is available under + +`/api/swagger` + +**Wallet API** is available under the context path `/api/` + +**Verifier portal API** is available under the context path `/verifier-api/` + +**Issuer portal API** is available under the context path `/issuer-api/` + +## Build & run the Web Wallet Kit + +_Gradle_ or _Docker_ can be used to build this project independently. Once running, one can access the Swagger API at http://localhost:8080/api/swagger + +### Gradle + + gradle build + +unzip package under build/distributions and switch into the new folder. Copy config-files _service-matrix.properties_ and _signatory.conf_ from the root folder and run the bash-script: + + ./bin/waltid-walletkit + +To run the backend you will execute: + ```waltid-walletkit run``` +To have issuers, you will have to execute: + ```waltid-walletkit --init-issuer``` + +### Docker + + docker build -t waltid/walletkit . + + docker run -it -p 8080:8080 waltid/walletkit + +## Running all components with Docker Compose + +To spawn the **backend** together with the **wallet frontend**, the **issuer-** and the **verifier-portal**, one can make use of the docker-compose configuration located in folder: + +`./docker/` + +In order to simply run everything, enter: + + docker-compose up + +This configuration will publish the following endpoints by default: +* **web wallet** on _**[HOSTNAME]:8080**_ + * wallet frontend: http://[HOSTNAME]:8080/ + * wallet API: http://[HOSTNAME]:8080/api/ +* **verifier portal** on _**[HOSTNAME]:8081**_ + * verifier frontend: http://[HOSTNAME]:8081/ + * verifier API: http://[HOSTNAME]:8081/verifier-api/ +* **issuer portal** on _**[HOSTNAME]:8082**_ + * issuer frontend: http://[HOSTNAME]:8082/ + * issuer API: http://[HOSTNAME]:8082/issuer-api/ + +*Note* + +**[HOSTNAME]** is your local computer name. Using **localhost**, not all features will work correctly. + +Visit the `./docker`. folder for adjusting the system config in the following files +* **docker-compose.yaml** - Docker config for launching containers, volumes & networking +* **ingress.conf** - Routing config +* **config/verifier-config.json** - verifier portal configuration +* **config/issuer-config.json** - issuer portal configuration + +## Initializing Wallet Kit as EBSI/ESSIF Issuer + +By specifying the optional startup parameter **--init-issuer** the walletkit can be initialized as issuer-backend in line with the EBSI/ESSIF ecosystem. Note that this is for demo-purpose only. + +``` +cd docker +docker pull waltid/walletkit +docker run -it -v $PWD:/waltid-walletkit/data-root -e WALTID_DATA_ROOT=./data-root waltid/walletkit --init-issuer + +# For the DID-method enter: "ebsi" +# For the bearer token copy/paste the value from: https://app.preprod.ebsi.eu/users-onboarding +``` + +The initialization routine will output the DID, which it registered on the EBSI/ESSIF ecosystem. + + +## Relevant Standards + +- [Self-Issued OpenID Provider v2](https://openid.bitbucket.io/connect/openid-connect-self-issued-v2-1_0.html) +- [OpenID Connect for Verifiable Presentations](https://openid.net/specs/openid-connect-4-verifiable-presentations-1_0-07.html) +- [OpenID Connect for Verifiable Credential Issuance](https://tlodderstedt.github.io/openid-connect-4-verifiable-credential-issuance-1_0-01.html) +- [EBSI Wallet Conformance](https://ec.europa.eu/digital-building-blocks/wikis/display/EBSIDOC/EBSI+Wallet+Conformance+Testing) +- [Verifiable Credentials Data Model 1.0](https://www.w3.org/TR/vc-data-model/) +- [Decentralized Identifiers (DIDs) v1.0](https://w3c.github.io/did-core/) +- [DID Method Rubric](https://w3c.github.io/did-rubric/) +- [did:web Decentralized Identifier Method Specification](https://w3c-ccg.github.io/did-method-web/) +- [The did:key Method v0.7](https://w3c-ccg.github.io/did-method-key/) + + +## License + +Licensed under the [Apache License, Version 2.0](https://github.com/walt-id/waltid-walletkit/blob/master/LICENSE) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ef7ed1e --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,130 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.7.10" + kotlin("plugin.serialization") version "1.6.10" + application + `maven-publish` +} + +group = "id.walt" +version = "0.7.0-SNAPSHOT" + +repositories { + mavenLocal() + mavenCentral() + maven("https://jitpack.io") + maven("https://maven.walt.id/repository/waltid/") + maven("https://maven.walt.id/repository/waltid-ssi-kit/") + maven("https://repo.danubetech.com/repository/maven-public/") +} + +dependencies { + // SSIKIT + implementation("id.walt:waltid-ssikit:1.2302271609.0") + //implementation("id.walt:waltid-ssikit-vclib:1.24.2") + + // Metaco + // implementation("com.metaco:sdk:2.1.0") + + implementation("io.javalin:javalin-bundle:4.6.4") + implementation("com.github.kmehrunes:javalin-jwt:0.3") + implementation("com.beust:klaxon:5.6") + implementation("com.nimbusds:oauth2-oidc-sdk:9.43.1") + + // CLI + implementation("com.github.ajalt.clikt:clikt-jvm:3.5.1") + implementation("com.github.ajalt.clikt:clikt:3.5.1") + + // Service-Matrix + implementation("id.walt.servicematrix:WaltID-ServiceMatrix:1.1.3") + + // Logging + //implementation("org.slf4j:slf4j-api:2.0.5") + implementation("org.slf4j:slf4j-simple:2.0.5") + implementation("io.github.microutils:kotlin-logging-jvm:3.0.4") + + // Ktor + implementation("io.ktor:ktor-client-jackson:2.2.1") + implementation("io.ktor:ktor-client-content-negotiation:2.2.1") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.2.1") + implementation("io.ktor:ktor-client-core:2.2.1") + implementation("io.ktor:ktor-client-cio:2.2.1") + implementation("io.ktor:ktor-client-logging:2.2.1") + implementation("io.ktor:ktor-client-auth:2.2.1") + + // Cache + implementation("io.github.pavleprica:kotlin-cache:1.2.0") + + // Testing + //testImplementation(kotlin("test-junit")) + testImplementation("io.mockk:mockk:1.13.2") + + testImplementation("io.kotest:kotest-runner-junit5:5.5.4") + testImplementation("io.kotest:kotest-assertions-core:5.5.4") + testImplementation("io.kotest:kotest-assertions-json:5.5.4") + + // HTTP + implementation("io.ktor:ktor-client-core:2.2.1") + implementation("io.ktor:ktor-client-content-negotiation:2.2.1") + implementation("io.ktor:ktor-client-cio:2.2.1") + implementation("io.ktor:ktor-client-logging:2.2.1") + implementation(kotlin("stdlib-jdk8")) + + // VC lib for custom credentials + implementation("id.walt:waltid-ssikit-vclib:1.24.0") +} + +tasks.withType { + kotlinOptions.jvmTarget = "16" +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +tasks.withType { + useJUnitPlatform() +} + +application { + mainClass.set("id.walt.MainKt") +} + +publishing { + publications { + create("mavenJava") { + pom { + name.set("walt.id SSI Wallet Kit") + description.set("Kotlin/Java wallet API backend, including issuer and verifier API backends.") + url.set("https://walt.id") + } + from(components["java"]) + } + } + + repositories { + maven { + url = uri("https://maven.walt.id/repository/waltid-ssi-kit/") + val usernameFile = File("secret_maven_username.txt") + val passwordFile = File("secret_maven_password.txt") + val secretMavenUsername = System.getenv()["MAVEN_USERNAME"] ?: if (usernameFile.isFile) { usernameFile.readLines()[0] } else { "" } + val secretMavenPassword = System.getenv()["MAVEN_PASSWORD"] ?: if (passwordFile.isFile) { passwordFile.readLines()[0] } else { "" } + + credentials { + username = secretMavenUsername + password = secretMavenPassword + } + } + } +} +val compileKotlin: KotlinCompile by tasks +compileKotlin.kotlinOptions { + jvmTarget = "16" +} +val compileTestKotlin: KotlinCompile by tasks +compileTestKotlin.kotlinOptions { + jvmTarget = "16" +} diff --git a/cheqd-credential-template.json b/cheqd-credential-template.json new file mode 100644 index 0000000..5e932ea --- /dev/null +++ b/cheqd-credential-template.json @@ -0,0 +1,19 @@ +{ + "@context": [ "https://www.w3.org/2018/credentials/v1" ], + "type": [ + "VerifiableCredential", + "TestCheqdCredential" + ], + + "id": "testCheqdCredential123", + "issuer": "did:cheqd:testnet:abc", + + "issuanceDate": "2020-11-03T00:00:00Z", + "validFrom": "2020-11-03T00:00:00Z", + + "some-stuff": "is here", + "credentialSubject": { + "id": "did:key:abc123", + "customAttribute": "some stuff" + } +} diff --git a/config/crypto.conf b/config/crypto.conf new file mode 100644 index 0000000..cdc4c32 --- /dev/null +++ b/config/crypto.conf @@ -0,0 +1,4 @@ +encryption-at-rest-key: "CHANGE-ME" # your secret key +key-format: PEM # [PEM / BASE64_DER / BASE64_RAW] +keys-root: keys # default: "keys" +alias-root: alias # default: "alias" diff --git a/config/did/cheqd.jwk.json b/config/did/cheqd.jwk.json new file mode 100644 index 0000000..f5a61b1 --- /dev/null +++ b/config/did/cheqd.jwk.json @@ -0,0 +1,9 @@ +{ + "kty": "OKP", + "d": "SKNkmI0Fs2vyo7owHQMRzC9OqhACmPadD-tsAv4HQ3M", + "use": "sig", + "crv": "Ed25519", + "kid": "259282edee7858a54cf59ca04bca1a37cc00cf332c77254b3f98828afc8acdbe", + "x": "JZKC7e54WKVM9ZygS8oaN8wAzzMsdyVLP5iCivyKzb4", + "alg": "EdDSA" +} diff --git a/config/did/did-cheqd.json b/config/did/did-cheqd.json new file mode 100644 index 0000000..fb6b703 --- /dev/null +++ b/config/did/did-cheqd.json @@ -0,0 +1,18 @@ +{ + "@context": "https://w3id.org/did-resolution/v1", + "id": "did:cheqd:testnet:z3XffJBaKvAqi2RL", + "controller": [ + "did:cheqd:testnet:z3XffJBaKvAqi2RL" + ], + "authentication": [ + "did:cheqd:testnet:z3XffJBaKvAqi2RL#key-1" + ], + "verificationMethod": [ + { + "id": "did:cheqd:testnet:z3XffJBaKvAqi2RL#key-1", + "type": "Ed25519VerificationKey2020", + "controller": "did:cheqd:testnet:z3XffJBaKvAqi2RL", + "publicKeyMultibase": "z3XffJBaKvAqi2RLLroggoq1MJW3Dy7NP4DYJuFqFjdyF" + } + ] +} diff --git a/config/fsStore.conf b/config/fsStore.conf new file mode 100644 index 0000000..ccbc15f --- /dev/null +++ b/config/fsStore.conf @@ -0,0 +1 @@ +dataRoot: "./data" diff --git a/config/issuer-config.json b/config/issuer-config.json new file mode 100644 index 0000000..a40bbde --- /dev/null +++ b/config/issuer-config.json @@ -0,0 +1,13 @@ +{ + "issuerUiUrl": "http://localhost:8082", + "issuerApiUrl": "http://localhost:8080/issuer-api/default", + "wallets": { + "waltid": { + "id": "waltid", + "url": "http://localhost:8080", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } +} diff --git a/config/s3Store.conf b/config/s3Store.conf new file mode 100644 index 0000000..79cd6c5 --- /dev/null +++ b/config/s3Store.conf @@ -0,0 +1,4 @@ +endpoint: "http://localhost:9000" +bucket: "ssikit" +access_key: "minioadmin" +secret_key: "minioadmin" diff --git a/config/signatory.conf b/config/signatory.conf new file mode 100644 index 0000000..025f4fe --- /dev/null +++ b/config/signatory.conf @@ -0,0 +1,8 @@ +proofConfig { + issuerDid="foobar" + issuerVerificationMethod="foobar" + proofType="LD_PROOF" + domain="foobar" + nonce="foobar" +} +templatesFolder: "vc-templates-runtime" diff --git a/config/verifier-config.json b/config/verifier-config.json new file mode 100644 index 0000000..3a42183 --- /dev/null +++ b/config/verifier-config.json @@ -0,0 +1,23 @@ +{ + "verifierUiUrl": "http://localhost:8081", + "verifierApiUrl": "http://localhost:8080/verifier-api/default", + "additionalPolicies": [ + ], + "wallets": { + "waltid": { + "id": "waltid", + "url": "http://localhost:8080", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + }, + "local": { + "id": "local", + "url": "http://localhost:8080", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "local wallet" + } + }, + "allowedWebhookHosts": [ "http://localhost", "http://wallet.local" ] +} diff --git a/config/wallet-config.json b/config/wallet-config.json new file mode 100644 index 0000000..cdd1b87 --- /dev/null +++ b/config/wallet-config.json @@ -0,0 +1,16 @@ +{ + "walletUiUrl": "http://localhost:4201", + "walletApiUrl": "http://localhost:8080/api", + "issuers": { + "waltid": { + "id": "waltid", + "url": "http://localhost:8080/issuer-api/default/oidc", + "description": "walt.id Issuer Portal" + }, + "onboarding@walt.id": { + "id": "onboarding@walt.id", + "url": "http://localhost:8080/onboarding-api/oidc", + "description": "walt.id On-Boarding service" + } + } +} diff --git a/docker/config/issuer-config.json b/docker/config/issuer-config.json new file mode 100644 index 0000000..d95937b --- /dev/null +++ b/docker/config/issuer-config.json @@ -0,0 +1,13 @@ +{ + "issuerUiUrl": "http://$EXTERNAL_HOSTNAME:8082", + "issuerApiUrl": "http://$EXTERNAL_HOSTNAME:8082/issuer-api/default", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "http://$EXTERNAL_HOSTNAME:8080", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } +} diff --git a/docker/config/verifier-config.json b/docker/config/verifier-config.json new file mode 100644 index 0000000..30a9983 --- /dev/null +++ b/docker/config/verifier-config.json @@ -0,0 +1,13 @@ +{ + "verifierUiUrl": "http://$EXTERNAL_HOSTNAME:8081", + "verifierApiUrl": "http://$EXTERNAL_HOSTNAME:8081/verifier-api/default", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "http://$EXTERNAL_HOSTNAME:8080", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } +} diff --git a/docker/config/wallet-config.json b/docker/config/wallet-config.json new file mode 100644 index 0000000..f1a2d03 --- /dev/null +++ b/docker/config/wallet-config.json @@ -0,0 +1,11 @@ +{ + "walletUiUrl": "http://$EXTERNAL_HOSTNAME:8080", + "walletApiUrl": "http://$EXTERNAL_HOSTNAME:8080/api", + "issuers": { + "walt.id": { + "id": "walt.id", + "url": "http://$EXTERNAL_HOSTNAME:8082/issuer-api/default/oidc", + "description": "walt.id Issuer Portal" + } + } +} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..cb94a65 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,37 @@ +version: "3.3" +services: + walletkit: + image: waltid/walletkit:latest # backend docker image + command: + - run + environment: + WALTID_DATA_ROOT: . #./data-root + WALTID_WALLET_BACKEND_BIND_ADDRESS: 0.0.0.0 + EXTERNAL_HOSTNAME: $HOSTNAME$COMPUTERNAME + volumes: + - .:/waltid-walletkit/data-root # data store volume incl. config files. + extra_hosts: + - "$HOSTNAME$COMPUTERNAME:host-gateway" + wallet-ui: + image: waltid/ssikit-web-wallet:latest # wallet web ui docker image + verifier-ui: + image: waltid/ssikit-verifier-portal:latest # verifier web ui docker image + issuer-ui: + image: waltid/ssikit-issuer-portal:latest # issuer web ui docker image + ingress: + image: nginx:1.15.10-alpine + ports: + - target: 80 + published: 8080 # wallet ui publish port + protocol: tcp + mode: host + - target: 81 + published: 8081 # verifier ui publish port + protocol: tcp + mode: host + - target: 82 + published: 8082 # issuer ui publish port + protocol: tcp + mode: host + volumes: + - ./ingress.conf:/etc/nginx/conf.d/default.conf # API gateway configuration diff --git a/docker/ingress.conf b/docker/ingress.conf new file mode 100644 index 0000000..8067e82 --- /dev/null +++ b/docker/ingress.conf @@ -0,0 +1,30 @@ + +server { + listen 80; + location ~* /(api|webjars|verifier-api|issuer-api)/ { + proxy_pass http://walletkit:8080; + } + location / { + proxy_pass http://wallet-ui:80/; + } +} + +server { + listen 81; + location ~* /(api|webjars|verifier-api|issuer-api)/ { + proxy_pass http://walletkit:8080; + } + location / { + proxy_pass http://verifier-ui:80/; + } +} + +server { + listen 82; + location ~* /(api|webjars|verifier-api|issuer-api)/ { + proxy_pass http://walletkit:8080; + } + location / { + proxy_pass http://issuer-ui:80/; + } +} diff --git a/fsStore.conf b/fsStore.conf new file mode 100644 index 0000000..ccbc15f --- /dev/null +++ b/fsStore.conf @@ -0,0 +1 @@ +dataRoot: "./data" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f72df95 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/k8s/dashboard-ingress.yaml b/k8s/dashboard-ingress.yaml new file mode 100644 index 0000000..ccc0eea --- /dev/null +++ b/k8s/dashboard-ingress.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dashboard-k8s + namespace: kubernetes-dashboard + annotations: + nginx.ingress.kubernetes.io/secure-backends: "true" + nginx.ingress.kubernetes.io/ssl-passthrough: "true" + nginx.ingress.kubernetes.io/backend-protocol: "HTTPS" + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - kube.walt.id + secretName: dashboard-tls-secret + rules: + - host: kube.walt.id + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: kubernetes-dashboard + port: + number: 443 diff --git a/k8s/deployment-cheqd.yaml b/k8s/deployment-cheqd.yaml new file mode 100644 index 0000000..4417c72 --- /dev/null +++ b/k8s/deployment-cheqd.yaml @@ -0,0 +1,364 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: wallet-config +data: + issuer-config.json: | + { + "issuerUiUrl": "https://issuer.cheqd.walt-test.cloud", + "issuerApiUrl": "https://issuer.cheqd.walt-test.cloud/issuer-api/default", + "issuerClientName": "walt.id Issuer Portal", + "wallets": { + "waltid": { + "id": "waltid", + "url": "https://wallet.cheqd.walt-test.cloud", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } + } + verifier-config.json: | + { + "verifierUiUrl": "https://verifier.cheqd.walt-test.cloud", + "verifierApiUrl": "https://verifier.cheqd.walt-test.cloud/verifier-api/default", + "wallets": { + "waltid": { + "id": "waltid", + "url": "https://wallet.cheqd.walt-test.cloud", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + }, + "allowedWebhookHosts": [ "https://integrations.cheqd.walt-test.cloud/callback/" ] + } + wallet-config.json: | + { + "walletUiUrl": "https://wallet.cheqd.walt-test.cloud", + "walletApiUrl": "https://wallet.cheqd.walt-test.cloud/api", + "issuers": { + "waltid": { + "id": "waltid", + "url": "https://issuer.cheqd.walt-test.cloud/issuer-api/default/oidc", + "description": "walt.id Issuer Portal" + }, + "yes.com": { + "id": "yes.com", + "url": "https://demo.sandbox.yes.com/essif/issuer/c2id", + "description": "yes.com Bank ID issuer" + }, + "onboarding@walt.id": { + "id": "onboarding@walt.id", + "url": "https://issuer.cheqd.walt-test.cloud/onboarding-api/oidc", + "description": "walt.id On-Boarding service" + } + } + } +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wallet-data-volume-claim +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 10Gi +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: walletkit +spec: + replicas: 1 + selector: + matchLabels: + app: walletkit + template: + metadata: + labels: + app: walletkit + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: walletkit + image: waltid/walletkit:latest + volumeMounts: + - name: wallet-config + mountPath: "/waltid/wallet/config/" + readOnly: true + - mountPath: "/waltid/wallet/data/" + name: wallet-data + env: + - name: WALTID_DATA_ROOT + value: "/waltid/wallet" + - name: WALTID_WALLET_BACKEND_BIND_ADDRESS + value: 0.0.0.0 + - name: WALTID_WALLET_AUTH_SECRET + value: 0b218176-d8f3-4a58-83db-fd328defc30f + args: + - run + ports: + - containerPort: 8080 + name: http-api + volumes: + - name: wallet-config + configMap: + name: wallet-config + - name: issuers-secret + secret: + secretName: issuers-secret + - name: wallet-data + persistentVolumeClaim: + claimName: wallet-data-volume-claim +--- +kind: Service +apiVersion: v1 +metadata: + name: walletkit +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: walletkit +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: web-wallet +spec: + replicas: 1 + selector: + matchLabels: + app: web-wallet + template: + metadata: + labels: + app: web-wallet + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-web-wallet + image: waltid/ssikit-web-wallet:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: web-wallet +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: web-wallet +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: verifier-portal +spec: + replicas: 1 + selector: + matchLabels: + app: verifier-portal + template: + metadata: + labels: + app: verifier-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-verifier-portal + image: waltid/ssikit-verifier-portal:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: verifier-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: verifier-portal +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: issuer-portal +spec: + replicas: 1 + selector: + matchLabels: + app: issuer-portal + template: + metadata: + labels: + app: issuer-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-issuer-portal + image: waltid/ssikit-issuer-portal:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: issuer-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: issuer-portal +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: walletkit + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: letsencrypt-prod + ingress.kubernetes.io/configuration-snippet: | + if ($host ~ ^(.+)\.waltid\.org$) { + return 301 https://$1.cheqd.walt-test.cloud$request_uri; + } +spec: + tls: + - hosts: + - wallet.cheqd.walt-test.cloud + - verifier.cheqd.walt-test.cloud + - issuer.cheqd.walt-test.cloud + secretName: wallet-tls-secret + rules: + - host: wallet.cheqd.walt-test.cloud + http: + paths: + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /verifier-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /onboarding-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /webjars + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /v2/nftkit/nft/ + pathType: Prefix + backend: + service: + name: nftkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: web-wallet + port: + number: 80 + - host: verifier.cheqd.walt-test.cloud + http: + paths: + - path: /verifier-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: verifier-portal + port: + number: 80 + - host: issuer.cheqd.walt-test.cloud + http: + paths: + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /onboarding-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: issuer-portal + port: + number: 80 diff --git a/k8s/deployment-dev.yaml b/k8s/deployment-dev.yaml new file mode 100644 index 0000000..999fb82 --- /dev/null +++ b/k8s/deployment-dev.yaml @@ -0,0 +1,364 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: wallet-config +data: + issuer-config.json: | + { + "issuerUiUrl": "https://issuer.walt-test.cloud", + "issuerApiUrl": "https://issuer.walt-test.cloud/issuer-api/default", + "issuerClientName": "walt.id Issuer Portal", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "https://wallet.walt-test.cloud", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } + } + verifier-config.json: | + { + "verifierUiUrl": "https://verifier.walt-test.cloud", + "verifierApiUrl": "https://verifier.walt-test.cloud/verifier-api/default", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "https://wallet.walt-test.cloud", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } + } + wallet-config.json: | + { + "walletUiUrl": "https://wallet.walt-test.cloud", + "walletApiUrl": "https://wallet.walt-test.cloud/api", + "issuers": { + "walt.id": { + "id": "walt.id", + "url": "https://issuer.walt-test.cloud/issuer-api/default/oidc", + "description": "walt.id Issuer Portal" + }, + "yes.com": { + "id": "yes.com", + "url": "https://demo.sandbox.yes.com/essif/issuer/c2id", + "description": "yes.com Bank ID issuer" + }, + "onboarding@walt.id": { + "id": "onboarding@walt.id", + "url": "https://issuer.walt-test.cloud/onboarding-api/oidc", + "description": "walt.id On-Boarding service" + } + } + } +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wallet-data-volume-claim +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 10Gi +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: walletkit +spec: + replicas: 1 + selector: + matchLabels: + app: walletkit + template: + metadata: + labels: + app: walletkit + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: walletkit + image: waltid/walletkit:latest + volumeMounts: + - name: wallet-config + mountPath: "/waltid/wallet/config/" + readOnly: true + - name: issuers-secret + mountPath: "/waltid/wallet/secrets" + readOnly: true + - mountPath: "/waltid/wallet/data/" + name: wallet-data + env: + - name: WALTID_DATA_ROOT + value: "/waltid/wallet" + - name: WALTID_WALLET_BACKEND_BIND_ADDRESS + value: 0.0.0.0 + args: + - run + ports: + - containerPort: 8080 + name: http-api + volumes: + - name: wallet-config + configMap: + name: wallet-config + - name: issuers-secret + secret: + secretName: issuers-secret + - name: wallet-data + persistentVolumeClaim: + claimName: wallet-data-volume-claim +--- +kind: Service +apiVersion: v1 +metadata: + name: walletkit +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: walletkit +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: web-wallet +spec: + replicas: 1 + selector: + matchLabels: + app: web-wallet + template: + metadata: + labels: + app: web-wallet + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-web-wallet + image: waltid/ssikit-web-wallet:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: web-wallet +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: web-wallet +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: verifier-portal +spec: + replicas: 1 + selector: + matchLabels: + app: verifier-portal + template: + metadata: + labels: + app: verifier-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-verifier-portal + image: waltid/ssikit-verifier-portal:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: verifier-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: verifier-portal +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: issuer-portal +spec: + replicas: 1 + selector: + matchLabels: + app: issuer-portal + template: + metadata: + labels: + app: issuer-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-issuer-portal + image: waltid/ssikit-issuer-portal:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: issuer-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: issuer-portal +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: walletkit + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: letsencrypt-prod + ingress.kubernetes.io/configuration-snippet: | + if ($host ~ ^(.+)\.waltid\.org$) { + return 301 https://$1.walt-test.cloud$request_uri; + } +spec: + tls: + - hosts: + - wallet.walt-test.cloud + - verifier.walt-test.cloud + - issuer.walt-test.cloud + secretName: wallet-tls-secret + rules: + - host: wallet.walt-test.cloud + http: + paths: + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /verifier-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /onboarding-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /webjars + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /v2/nftkit/nft/ + pathType: Prefix + backend: + service: + name: nftkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: web-wallet + port: + number: 80 + - host: verifier.walt-test.cloud + http: + paths: + - path: /verifier-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: verifier-portal + port: + number: 80 + - host: issuer.walt-test.cloud + http: + paths: + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /onboarding-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: issuer-portal + port: + number: 80 \ No newline at end of file diff --git a/k8s/deployment-jff.yaml b/k8s/deployment-jff.yaml new file mode 100644 index 0000000..980ee79 --- /dev/null +++ b/k8s/deployment-jff.yaml @@ -0,0 +1,169 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: jff-config +data: + issuer-config.json: | + { + "issuerUiUrl": "https://jff.walt.id", + "issuerApiUrl": "https://jff.walt.id/issuer-api/default", + "issuerClientName": "walt.id JFF Issuer Portal", + "issuerDid": "did:jwk:eyJrdHkiOiJFQyIsInVzZSI6InNpZyIsImNydiI6IlAtMjU2Iiwia2lkIjoiOWUzZTI3MjliZWMzNDU3YTgzMGQ3MGFkNDNmZmMzYzkiLCJ4IjoiMTlFWXV4aWJ2bGpWUTdORXo3SFNwRjlTcnZPTTJmMkJPaE9UWGlsa0I3OCIsInkiOiJUdGI4WTdQVmhReGZ4UURWQkFIYklvbUNhWWo0VGt3ZEZ3OHMwVWxJOVFZIiwiYWxnIjoiRVMyNTYifQ", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "https://wallet.walt-test.cloud", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } + } +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jff-data-volume-claim +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 10Gi +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: walletkit +spec: + replicas: 1 + selector: + matchLabels: + app: walletkit + template: + metadata: + labels: + app: walletkit + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: walletkit + image: waltid/walletkit:latest + volumeMounts: + - name: jff-config + mountPath: "/waltid/wallet/config/" + readOnly: true + - mountPath: "/waltid/wallet/data/" + name: jff-data + env: + - name: WALTID_DATA_ROOT + value: "/waltid/wallet" + - name: WALTID_WALLET_BACKEND_BIND_ADDRESS + value: 0.0.0.0 + args: + - run + ports: + - containerPort: 8080 + name: http-api + volumes: + - name: jff-config + configMap: + name: jff-config + - name: jff-data + persistentVolumeClaim: + claimName: jff-data-volume-claim +--- +kind: Service +apiVersion: v1 +metadata: + name: walletkit +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: walletkit +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: issuer-portal +spec: + replicas: 1 + selector: + matchLabels: + app: issuer-portal + template: + metadata: + labels: + app: issuer-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-issuer-portal + image: waltid/ssikit-issuer-portal:latest + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: issuer-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: issuer-portal +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: jffingress + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - jff.walt.id + secretName: jff-tls-secret + rules: + - host: jff.walt.id + http: + paths: + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /webjars + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: issuer-portal + port: + number: 80 diff --git a/k8s/deployment-prod.yaml b/k8s/deployment-prod.yaml new file mode 100644 index 0000000..0128e5c --- /dev/null +++ b/k8s/deployment-prod.yaml @@ -0,0 +1,335 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: wallet-config +data: + issuer-config.json: | + { + "issuerUiUrl": "https://issuer.walt.id", + "issuerApiUrl": "https://issuer.walt.id/issuer-api/default", + "issuerClientName": "walt.id Issuer Portal", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "https://wallet.walt.id", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } + } + verifier-config.json: | + { + "verifierUiUrl": "https://verifier.walt.id", + "verifierApiUrl": "https://verifier.walt.id/verifier-api/default", + "wallets": { + "walt.id": { + "id": "walt.id", + "url": "https://wallet.walt.id", + "presentPath": "api/siop/initiatePresentation/", + "receivePath" : "api/siop/initiateIssuance/", + "description": "walt.id web wallet" + } + } + } + wallet-config.json: | + { + "walletUiUrl": "https://wallet.walt.id", + "walletApiUrl": "https://wallet.walt.id/api", + "issuers": { + "walt.id": { + "id": "walt.id", + "url": "https://wallet.walt.id/issuer-api/default/oidc", + "description": "walt.id Issuer Portal" + }, + "yes.com": { + "id": "yes.com", + "url": "https://demo.sandbox.yes.com/essif/issuer/c2id", + "description": "yes.com Bank ID issuer" + } + } + } +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: wallet-data-volume-claim + namespace: default +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 10Gi +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: walletkit +spec: + replicas: 1 + selector: + matchLabels: + app: walletkit + template: + metadata: + labels: + app: walletkit + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: walletkit + image: waltid/walletkit:_VERSION_TAG_ + volumeMounts: + - name: wallet-config + mountPath: "/waltid/wallet/config/" + readOnly: true + - name: issuers-secret + mountPath: "/waltid/wallet/secrets" + readOnly: true + - mountPath: "/waltid/wallet/data/" + name: wallet-data + env: + - name: WALTID_DATA_ROOT + value: "/waltid/wallet" + - name: WALTID_WALLET_BACKEND_BIND_ADDRESS + value: 0.0.0.0 + args: + - run + ports: + - containerPort: 8080 + name: http-api + volumes: + - name: wallet-config + configMap: + name: wallet-config + - name: issuers-secret + secret: + secretName: issuers-secret + - name: wallet-data + persistentVolumeClaim: + claimName: wallet-data-volume-claim +--- +kind: Service +apiVersion: v1 +metadata: + name: walletkit +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: walletkit +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: web-wallet +spec: + replicas: 1 + selector: + matchLabels: + app: web-wallet + template: + metadata: + labels: + app: web-wallet + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-web-wallet + image: waltid/ssikit-web-wallet:_VERSION_TAG_ + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: web-wallet +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: web-wallet +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: verifier-portal +spec: + replicas: 1 + selector: + matchLabels: + app: verifier-portal + template: + metadata: + labels: + app: verifier-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-verifier-portal + image: waltid/ssikit-verifier-portal:_VERSION_TAG_ + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: verifier-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: verifier-portal +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: issuer-portal +spec: + replicas: 1 + selector: + matchLabels: + app: issuer-portal + template: + metadata: + labels: + app: issuer-portal + annotations: + deployment/id: "_DEFAULT_DEPLOYMENT_" + spec: + containers: + - name: ssikit-issuer-portal + image: waltid/ssikit-issuer-portal:_VERSION_TAG_ + ports: + - containerPort: 80 + name: http-api +--- +kind: Service +apiVersion: v1 +metadata: + name: issuer-portal +spec: + ports: + - name: http + port: 80 + targetPort: http-api + protocol: TCP + selector: + app: issuer-portal +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: walletkit + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - wallet.walt.id + - verifier.walt.id + - issuer.walt.id + secretName: wallet-tls-secret + rules: + - host: wallet.walt.id + http: + paths: + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /verifier-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /webjars + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /v2/nftkit/nft/ + pathType: Prefix + backend: + service: + name: nftkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: web-wallet + port: + number: 80 + - host: verifier.walt.id + http: + paths: + - path: /verifier-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: verifier-portal + port: + number: 80 + - host: issuer.walt.id + http: + paths: + - path: /issuer-api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: /api/ + pathType: Prefix + backend: + service: + name: walletkit + port: + number: 80 + - path: / + pathType: Prefix + backend: + service: + name: issuer-portal + port: + number: 80 diff --git a/sample-data/severin@walt.id/did/created/did%3Akey%3Az6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt b/sample-data/severin@walt.id/did/created/did%3Akey%3Az6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt new file mode 100644 index 0000000..3f86b83 --- /dev/null +++ b/sample-data/severin@walt.id/did/created/did%3Akey%3Az6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt @@ -0,0 +1,33 @@ +{ + "assertionMethod" : [ + "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt" + ], + "authentication" : [ + "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt" + ], + "capabilityDelegation" : [ + "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt" + ], + "capabilityInvocation" : [ + "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt" + ], + "@context" : "https://w3id.org/did/v1", + "id" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", + "keyAgreement" : [ + "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6LSiWG8f5y2ED2LfMESDaKd4joqWFXXya2sa953LwSKgSXa" + ], + "verificationMethod" : [ + { + "controller" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", + "id" : "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", + "publicKeyBase58" : "69CBp8TnNsntsQ2pkh7MoU1LmG8FsWB4eyQJfJunS5xW", + "type" : "Ed25519VerificationKey2018" + }, + { + "controller" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", + "id" : "z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt#z6LSiWG8f5y2ED2LfMESDaKd4joqWFXXya2sa953LwSKgSXa", + "publicKeyBase58" : "7q5y8nAA8kJbZxrfgvofk9bMf6zRGxrihAMMrUnny4kp", + "type" : "X25519KeyAgreementKey2019" + } + ] +} \ No newline at end of file diff --git a/sample-data/severin@walt.id/keystore/alias/a964ad8a6d384bbfbf9f5e8b5d46e1bc b/sample-data/severin@walt.id/keystore/alias/a964ad8a6d384bbfbf9f5e8b5d46e1bc new file mode 100644 index 0000000..8eb9ce5 --- /dev/null +++ b/sample-data/severin@walt.id/keystore/alias/a964ad8a6d384bbfbf9f5e8b5d46e1bc @@ -0,0 +1 @@ +a964ad8a6d384bbfbf9f5e8b5d46e1bc \ No newline at end of file diff --git a/sample-data/severin@walt.id/keystore/alias/did%3Akey%3Az6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt b/sample-data/severin@walt.id/keystore/alias/did%3Akey%3Az6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt new file mode 100644 index 0000000..8eb9ce5 --- /dev/null +++ b/sample-data/severin@walt.id/keystore/alias/did%3Akey%3Az6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt @@ -0,0 +1 @@ +a964ad8a6d384bbfbf9f5e8b5d46e1bc \ No newline at end of file diff --git a/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/aliases b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/aliases new file mode 100644 index 0000000..2d51ab0 --- /dev/null +++ b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/aliases @@ -0,0 +1,2 @@ +a964ad8a6d384bbfbf9f5e8b5d46e1bc +did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt \ No newline at end of file diff --git a/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/enc-privkey b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/enc-privkey new file mode 100644 index 0000000..8a45d00 --- /dev/null +++ b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/enc-privkey @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEICW6AMuorx657CJNYjm1KKQEnJ8sS+SedyM9pXlNvB+R +-----END PRIVATE KEY----- \ No newline at end of file diff --git a/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/enc-pubkey b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/enc-pubkey new file mode 100644 index 0000000..4053abc --- /dev/null +++ b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/enc-pubkey @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEATGO6yUbN3/jo4KXOdsILos8E+o4qBsppQ5dNleU115s= +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/meta b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/meta new file mode 100644 index 0000000..34b0129 --- /dev/null +++ b/sample-data/severin@walt.id/keystore/keys/a964ad8a6d384bbfbf9f5e8b5d46e1bc/meta @@ -0,0 +1 @@ +EdDSA_Ed25519;SUN \ No newline at end of file diff --git a/sample-data/severin@walt.id/vc/custodian/identity%23VerifiableDiploma%2366c4c129-fe6e-4498-ba73-1f145a2794b2 b/sample-data/severin@walt.id/vc/custodian/identity%23VerifiableDiploma%2366c4c129-fe6e-4498-ba73-1f145a2794b2 new file mode 100644 index 0000000..eb768ee --- /dev/null +++ b/sample-data/severin@walt.id/vc/custodian/identity%23VerifiableDiploma%2366c4c129-fe6e-4498-ba73-1f145a2794b2 @@ -0,0 +1 @@ +{"@context" : ["https://www.w3.org/2018/credentials/v1"], "credentialSchema" : {"id" : "https://api.preprod.ebsi.eu/trusted-schemas-registry/v1/schemas/0xbf78fc08a7a9f28f5479f58dea269d3657f54f13ca37d380cd4e92237fb691dd", "type" : "JsonSchemaValidator2018"}, "credentialSubject" : {"awardingOpportunity" : {"awardingBody" : {"homepage" : "https://leaston.bcdiploma.com/", "id" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "preferredName" : "Leaston University", "registration" : "0597065J"}, "endedAtTime" : "2020-06-26T00:00:00Z", "id" : "https://leaston.bcdiploma.com/law-economics-management#AwardingOpportunity", "identifier" : "https://certificate-demo.bcdiploma.com/check/87ED2F2270E6C41456E94B86B9D9115B4E35BCCAD200A49B846592C14F79C86BV1Fnbllta0NZTnJkR3lDWlRmTDlSRUJEVFZISmNmYzJhUU5sZUJ5Z2FJSHpWbmZZ", "location" : "AUSTRIA", "startedAtTime" : "2019-09-02T00:00:00Z"}, "dateOfBirth" : "1983-07-05", "familyName" : "STAMPLER", "givenNames" : "Severin", "gradingScheme" : {"id" : "https://leaston.bcdiploma.com/law-economics-management#GradingScheme", "title" : "Lower Second-Class Honours"}, "id" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "identifier" : "0904008084H", "learningAchievement" : {"additionalNote" : ["DISTRIBUTION MANAGEMENT"], "description" : "MARKETING AND SALES", "id" : "https://leaston.bcdiploma.com/law-economics-management#LearningAchievment", "title" : "MASTERS LAW, ECONOMICS AND MANAGEMENT"}, "learningSpecification" : {"ectsCreditPoints" : 120, "eqfLevel" : 7, "id" : "https://leaston.bcdiploma.com/law-economics-management#LearningSpecification", "iscedfCode" : ["7"], "nqfLevel" : ["7"]}}, "id" : "identity#VerifiableDiploma#66c4c129-fe6e-4498-ba73-1f145a2794b2", "issuanceDate" : "2021-11-04T13:21:09Z", "issuer" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "proof" : {"created" : "2021-11-04T12:21:51Z", "creator" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "domain" : "https://api.preprod.ebsi.eu", "jws" : "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJFZERTQSJ9..2fm-tErR_vPnWP-MmXnhRF175hxxXStqG-LfOvFrYWTPf1Iefv2GNVJxC23c4Wjk-eREdNHYsGB_2VJ7pWVFBw", "nonce" : "d81ab826-5bbb-453f-868d-ab9fd6e66a27", "type" : "Ed25519Signature2018"}, "validFrom" : "2021-11-04T13:21:09Z", "type" : ["VerifiableCredential", "VerifiableAttestation", "VerifiableDiploma"]} \ No newline at end of file diff --git a/sample-data/severin@walt.id/vc/custodian/identity%23VerifiableId%238965c872-dba0-4560-81dd-8a049b3d6506 b/sample-data/severin@walt.id/vc/custodian/identity%23VerifiableId%238965c872-dba0-4560-81dd-8a049b3d6506 new file mode 100644 index 0000000..b3569e0 --- /dev/null +++ b/sample-data/severin@walt.id/vc/custodian/identity%23VerifiableId%238965c872-dba0-4560-81dd-8a049b3d6506 @@ -0,0 +1 @@ +{"@context" : ["https://www.w3.org/2018/credentials/v1"], "credentialSchema" : {"id" : "https://api.preprod.ebsi.eu/trusted-schemas-registry/v1/schemas/0x2488fd38783d65e4fd46e7889eb113743334dbc772b05df382b8eadce763101b", "type" : "JsonSchemaValidator2018"}, "credentialSubject" : {"currentAddress" : "Siebenbrunnengasse 10/1/7, 1050 Wien, AUSTRIA", "dateOfBirth" : "1983-07-05", "familyName" : "STAMPLER", "firstName" : "Severin", "gender" : "MALE", "id" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "nameAndFamilyNameAtBirth" : "Jane DOE", "personalIdentifier" : "0904008084H", "placeOfBirth" : "GRAZ, AUSTRIA"}, "evidence" : {"documentPresence" : ["Physical"], "evidenceDocument" : ["Passport"], "subjectPresence" : "Physical", "type" : ["DocumentVerification"], "verifier" : "did:ebsi:2A9BZ9SUe6BatacSpvs1V5CdjHvLpQ7bEsi2Jb6LdHKnQxaN"}, "id" : "identity#VerifiableId#8965c872-dba0-4560-81dd-8a049b3d6506", "issuanceDate" : "2021-08-31T00:00:00Z", "issuer" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "proof" : {"created" : "2021-10-19T14:23:28Z", "creator" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "domain" : "https://api.preprod.ebsi.eu", "jws" : "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJFZERTQSJ9..FOw7spReOih7p-2V5G7rRFbo-0aEmy0Dz8C5chFvjaFp_etOQjHo7bSeaHYl5pOeB24v5kwTBPZ-UG7m2A1bDQ", "nonce" : "bb6bc626-f0c3-4153-97d3-f9edf16ab117", "type" : "Ed25519Signature2018"}, "validFrom" : "2021-08-31T00:00:00Z", "type" : ["VerifiableCredential", "VerifiableAttestation", "VerifiableId"]} \ No newline at end of file diff --git a/sample-data/severin@walt.id/vc/signatory/identity%23VerifiableId%238965c872-dba0-4560-81dd-8a049b3d6506 b/sample-data/severin@walt.id/vc/signatory/identity%23VerifiableId%238965c872-dba0-4560-81dd-8a049b3d6506 new file mode 100644 index 0000000..b3569e0 --- /dev/null +++ b/sample-data/severin@walt.id/vc/signatory/identity%23VerifiableId%238965c872-dba0-4560-81dd-8a049b3d6506 @@ -0,0 +1 @@ +{"@context" : ["https://www.w3.org/2018/credentials/v1"], "credentialSchema" : {"id" : "https://api.preprod.ebsi.eu/trusted-schemas-registry/v1/schemas/0x2488fd38783d65e4fd46e7889eb113743334dbc772b05df382b8eadce763101b", "type" : "JsonSchemaValidator2018"}, "credentialSubject" : {"currentAddress" : "Siebenbrunnengasse 10/1/7, 1050 Wien, AUSTRIA", "dateOfBirth" : "1983-07-05", "familyName" : "STAMPLER", "firstName" : "Severin", "gender" : "MALE", "id" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "nameAndFamilyNameAtBirth" : "Jane DOE", "personalIdentifier" : "0904008084H", "placeOfBirth" : "GRAZ, AUSTRIA"}, "evidence" : {"documentPresence" : ["Physical"], "evidenceDocument" : ["Passport"], "subjectPresence" : "Physical", "type" : ["DocumentVerification"], "verifier" : "did:ebsi:2A9BZ9SUe6BatacSpvs1V5CdjHvLpQ7bEsi2Jb6LdHKnQxaN"}, "id" : "identity#VerifiableId#8965c872-dba0-4560-81dd-8a049b3d6506", "issuanceDate" : "2021-08-31T00:00:00Z", "issuer" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "proof" : {"created" : "2021-10-19T14:23:28Z", "creator" : "did:key:z6MkjbTEQNiDiRHMytsXSG5CeZZLaqQ7HPRRLzKEVasoMJjt", "domain" : "https://api.preprod.ebsi.eu", "jws" : "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJFZERTQSJ9..FOw7spReOih7p-2V5G7rRFbo-0aEmy0Dz8C5chFvjaFp_etOQjHo7bSeaHYl5pOeB24v5kwTBPZ-UG7m2A1bDQ", "nonce" : "bb6bc626-f0c3-4153-97d3-f9edf16ab117", "type" : "Ed25519Signature2018"}, "validFrom" : "2021-08-31T00:00:00Z", "type" : ["VerifiableCredential", "VerifiableAttestation", "VerifiableId"]} \ No newline at end of file diff --git a/secrets/issuers.json b/secrets/issuers.json new file mode 100644 index 0000000..f381aef --- /dev/null +++ b/secrets/issuers.json @@ -0,0 +1,8 @@ +{ + "secrets": { + "walt.id": { + "client_id": "FOO", + "client_secret": "BAR" + } + } +} diff --git a/service-matrix.properties b/service-matrix.properties new file mode 100644 index 0000000..aa16fe4 --- /dev/null +++ b/service-matrix.properties @@ -0,0 +1,16 @@ +id.walt.services.ecosystems.essif.didebsi.DidEbsiService=id.walt.services.ecosystems.essif.didebsi.WaltIdDidEbsiService +id.walt.services.ecosystems.essif.jsonrpc.JsonRpcService=id.walt.services.ecosystems.essif.jsonrpc.WaltIdJsonRpcService +id.walt.services.vc.JsonLdCredentialService=id.walt.services.vc.WaltIdJsonLdCredentialService +id.walt.services.vc.JwtCredentialService=id.walt.services.vc.WaltIdJwtCredentialService +id.walt.services.crypto.CryptoService=id.walt.services.crypto.SunCryptoService +id.walt.services.keystore.KeyStoreService=id.walt.services.keystore.SqlKeyStoreService +id.walt.services.key.KeyService=id.walt.services.key.WaltIdKeyService +id.walt.services.jwt.JwtService=id.walt.services.jwt.WaltIdJwtService +id.walt.services.vcstore.VcStoreService=id.walt.services.vcstore.FileSystemVcStoreService +id.walt.services.hkvstore.HKVStoreService=id.walt.services.hkvstore.FileSystemHKVStore:config/fsStore.conf +id.walt.services.context.ContextManager=id.walt.services.context.WaltIdContextManager +id.walt.signatory.Signatory=id.walt.signatory.WaltIdSignatory:config/signatory.conf +id.walt.custodian.Custodian=id.walt.custodian.WaltIdCustodian +id.walt.auditor.Auditor=id.walt.auditor.WaltIdAuditor +id.walt.services.ecosystems.gaiax.GaiaxService=id.walt.services.ecosystems.gaiax.WaltIdGaiaxService +id.walt.verifier.backend.VerifierManager=id.walt.verifier.backend.DefaultVerifierManager diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..df0c3fa --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ + +rootProject.name = "waltid-walletkit" + diff --git a/signatory.conf b/signatory.conf new file mode 100644 index 0000000..33edfc9 --- /dev/null +++ b/signatory.conf @@ -0,0 +1,9 @@ + + +proofConfig { + issuerDid="todo" + issuerVerificationMethod="todo" + proofType="LD_PROOF" + domain="todo" + nonce="todo" +} diff --git a/src/main/kotlin/id/walt/Main.kt b/src/main/kotlin/id/walt/Main.kt new file mode 100644 index 0000000..2d8877f --- /dev/null +++ b/src/main/kotlin/id/walt/Main.kt @@ -0,0 +1,74 @@ +package id.walt + +import com.github.ajalt.clikt.core.subcommands +import id.walt.cli.* +import id.walt.multitenancy.ConfigureTenantCmd +import id.walt.multitenancy.TenantCmd +import id.walt.servicematrix.ServiceMatrix +import id.walt.servicematrix.ServiceRegistry +import id.walt.services.context.ContextManager +import id.walt.webwallet.backend.cli.ConfigCmd +import id.walt.webwallet.backend.cli.RunCmd +import id.walt.webwallet.backend.cli.WalletCmd +import id.walt.webwallet.backend.context.WalletContextManager +import kotlinx.coroutines.runBlocking + + +val WALTID_WALLET_BACKEND_PORT = System.getenv("WALTID_WALLET_BACKEND_PORT")?.toIntOrNull() ?: 8080 +var WALTID_WALLET_BACKEND_BIND_ADDRESS = System.getenv("WALTID_WALLET_BACKEND_BIND_ADDRESS") ?: "0.0.0.0" + +val WALTID_DATA_ROOT = /*System.getenv("WALTID_DATA_ROOT") ?:*/ "." + +fun main(args: Array): Unit = runBlocking { + + ServiceMatrix("service-matrix.properties") + ServiceRegistry.registerService(WalletContextManager) + WalletCmd().subcommands( + RunCmd(), + ConfigCmd().subcommands( + KeyCommand().subcommands( + GenKeyCommand(), + ListKeysCommand(), + ImportKeyCommand(), + ExportKeyCommand() + ), + DidCommand().subcommands( + CreateDidCommand(), + ResolveDidCommand(), + ListDidsCommand(), + ImportDidCommand() + ), + EssifCommand().subcommands( + EssifOnboardingCommand(), + EssifAuthCommand(), +// EssifVcIssuanceCommand(), +// EssifVcExchangeCommand(), + EssifDidCommand().subcommands( + EssifDidRegisterCommand() + ) + ), + VcCommand().subcommands( + VcIssueCommand(), + PresentVcCommand(), + VerifyVcCommand(), + ListVcCommand(), + VerificationPoliciesCommand().subcommands( + ListVerificationPoliciesCommand(), + CreateDynamicVerificationPolicyCommand(), + RemoveDynamicVerificationPolicyCommand() + ), + VcTemplatesCommand().subcommands( + VcTemplatesListCommand(), + VcTemplatesImportCommand(), + VcTemplatesExportCommand(), + VcTemplatesRemoveCommand() + ), + VcImportCommand() + ), + TenantCmd().subcommands( + ConfigureTenantCmd() + ), + ServeCommand() + ) + ).main(args) +} diff --git a/src/main/kotlin/id/walt/customTemplates/EHIC.kt b/src/main/kotlin/id/walt/customTemplates/EHIC.kt new file mode 100644 index 0000000..b7dccb5 --- /dev/null +++ b/src/main/kotlin/id/walt/customTemplates/EHIC.kt @@ -0,0 +1,138 @@ +package id.walt.customTemplates + +import com.beust.klaxon.Json +import id.walt.vclib.model.* +import id.walt.vclib.registry.VerifiableCredentialMetadata +import model.* + + +data class EHIC( + @Json(name = "@context") var context: List? = listOf("https://www.w3.org/2018/credentials/v1"), + @Json(serializeNull = false) var credentialStatus: CredentialStatus? = null, + @Json(serializeNull = false) override var credentialSubject: CredentialSubject? = null, + @Json(serializeNull = false) override var expirationDate: String? = null, // "2024-01-01T20:38:38Z" + @Json(serializeNull = false) override var id: String? = "did:ebsi:zsSgDXeYPhZ3AuKhTFneDf1", // "urn:uuid:27e83c2d-d230-43a7-9229-2e72d3570a27" + @Json(serializeNull = false) override var issued: String? = "2022-12-30T15:25:20Z", // "2022-12-30T15:25:20Z" + @Json(serializeNull = false) override var issuer: String? = "did:ebsi:z25Hi99z7n2tyKnfkU3p6SyW", // "did:ebsi:z25Hi99z7n2tyKnfkU3p6SyW" + @Json(serializeNull = false) override val credentialSchema: CredentialSchema? = null, + @Json(serializeNull = false) override var validFrom: String? = null, + @Json(serializeNull = false) override var proof: Proof? = null +) : AbstractVerifiableCredential(type) { + data class CredentialStatus( + @Json(serializeNull = false) var id: String? = null, // "https://essif.europa.eu/status/identity#verifiableID#1dee355d-0432-4910-ac9c-70d89e8d674e" + @Json(serializeNull = false) var type: String? = null // "CredentialStatusList2020" + ) + + data class CredentialSubject( + @Json(serializeNull = false) override var id: String? = "did:ebsi:zsSgDXeYPhZ3AuKhTFneDf1", // "urn:uuid:27e83c2d-d230-43a7-9229-2e72d3570a27", + @Json(serializeNull = false) var name: String? = null, // "Amaador" + @Json(serializeNull = false) var givenNames: String? = null, // "Soufiane" + @Json(serializeNull = false) var dateOfBirth: String? = null, // "02/04/1999" + @Json(serializeNull = false) var personalIdentificationNumber: String? = null, // "012345678" + @Json(serializeNull = false) var identificationOfTheInstitution: String? = null, // "3311 - Zilveren Kruis" + @Json(serializeNull = false) var identificationNumberOfTheCard: String? = null, // "01234567890123456789" + @Json(serializeNull = false) var expiryDate: String? = null, // "12/12/2024" + @Json(serializeNull = false) var insurer: model.Insurer? = null, + @Json(serializeNull = false) var bankDetails: model.BankDetails? = null, + @Json(serializeNull = false) var address: model.Address? = null, + @Json(serializeNull = false) var telephone: model.Telephone? = null + ) : id.walt.vclib.model.CredentialSubject() { + data class Insurer( + @Json(serializeNull = false) var identificationNumber: String? = null, // "3311" + @Json(serializeNull = false) var organisationName: String? = null, // "Zilveren Kruis" + @Json(serializeNull = false) var polisNumber: String? = null, // "12345678" + @Json(serializeNull = false, name = "Insurance") var insurance: Insurance? = null, + @Json(serializeNull = false, name = "Address") var address: Address? = null, + @Json(serializeNull = false, name = "Telephone") var telephone: Telephone? = null + ) { + data class Insurance( + @Json(serializeNull = false) var startDate: String? = null, // "01-01-2023" + @Json(serializeNull = false) var endDate: String? = null, // "31-01-2024" + @Json(serializeNull = false) var insuranceType: String? = null // "Basic Insured" + ) + } + + data class BankDetails( + @Json(serializeNull = false) var bankName: String? = null, // "ING" + @Json(serializeNull = false) var bankCode: String? = null, // "INGBNL2A" + @Json(serializeNull = false) var accountNumber: String? = null // "NL85INGB0001234567" + ) + + data class Address( + @Json(serializeNull = false) var street: String? = null, // "1e Jacob van Campenstr" + @Json(serializeNull = false) var houseNumber: String? = null, // "15" + @Json(serializeNull = false) var postcode: String? = null, // "1012 NX " + @Json(serializeNull = false) var residence: String? = null, // "Hoogmade" + @Json(serializeNull = false) var municipality: String? = null, // "Kaag en Braassem" + @Json(serializeNull = false) var country: String? = null, // "Netherlands" + @Json(serializeNull = false) var addressType: String? = null // "Residential/residence address" + ) + + data class Telephone( + @Json(serializeNull = false) var phoneNumber: String? = null, // "+31505233333" + @Json(serializeNull = false) var numberType: String? = null // "Business" + ) + + } + companion object : VerifiableCredentialMetadata( + type = listOf("VerifiableCredential", "EuropeanHealthInsuranceCard"), + template = { + EHIC( + credentialStatus = CredentialStatus( + id = "https://api-pilot.ebsi.eu/trusted-schemas-registry/v1/schemas/", + type = "CredentialStatusList2020" + ), + credentialSubject = CredentialSubject( + id = "did:ebsi:123456789", + name ="Doe", + givenNames = "John", + dateOfBirth = "1999-04-02", + personalIdentificationNumber = "012345678", + identificationOfTheInstitution = "3311 - Zilveren Kruis", + identificationNumberOfTheCard = "01234567890123456789", + insurer = Insurer( + identificationNumber = "3311", + organisationName = "Zilveren Kruis", + polisNumber = "12345678", + insurance = Insurance( + startDate = "2023-01-01", + endDate = "2024-01-31", + insuranceType = "Basic Insured" + ), + address = Address( + street = "Postbus", + houseNumber = "34000", + postcode = "7500 KC", + residence = "Enschede", + country = "Netherlands" + ), + telephone = Telephone( + phoneNumber = "+31505233333", + numberType = "Business" + ) + ), + expiryDate = "2024-01-31", + bankDetails = BankDetails( + bankName = "ING", + bankCode = "INGBNL2A", + accountNumber = "NL85INGB0001234567" + ), + address = Address( + street = "1e Jacob van Campenstr", + houseNumber = "15", + postcode = "1012 NX", + residence = "Hoogmade", + municipality = "Kaag en Braasem", + addressType = "Residential/residence address", + country = "Netherlands" + ), + telephone = Telephone( + phoneNumber = "+311725233111", + numberType = "private" + ) + + ), + issuer = "did:ebsi:zr2rWDHHrUCdZAW7wsSb5nQ", //SIGNATORY DID + ) + }) +} \ No newline at end of file diff --git a/src/main/kotlin/id/walt/customTemplates/Insurer.kt b/src/main/kotlin/id/walt/customTemplates/Insurer.kt new file mode 100644 index 0000000..4ccdeb2 --- /dev/null +++ b/src/main/kotlin/id/walt/customTemplates/Insurer.kt @@ -0,0 +1,10 @@ +package model + +class Insurer ( + var identificationNumber: String, + var organisationName: String, + var polisNumber: String, + var insurance: Insurance, + var address: Address, + var telephone: Telephone + ) \ No newline at end of file diff --git a/src/main/kotlin/id/walt/customTemplates/User.kt b/src/main/kotlin/id/walt/customTemplates/User.kt new file mode 100644 index 0000000..2d50758 --- /dev/null +++ b/src/main/kotlin/id/walt/customTemplates/User.kt @@ -0,0 +1,21 @@ +package model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.LocalDate + +data class User( + @JsonProperty("name") var name: String, + @JsonProperty var givenNames: String, + @JsonProperty var dateOfBirth: LocalDate, + @JsonProperty var personalIdentificationNumber: String, + @JsonProperty var identificationOfTheInstitution: String, + @JsonProperty var identificationNumberOfTheCard: String, + @JsonProperty var insurer: Insurer, + @JsonProperty var expiryDate: LocalDate, + @JsonProperty var bankDetails: BankDetails, + @JsonProperty var address: Address, + @JsonProperty var telephone: Telephone +) + + + diff --git a/src/main/kotlin/id/walt/customTemplates/UserMetaData.kt b/src/main/kotlin/id/walt/customTemplates/UserMetaData.kt new file mode 100644 index 0000000..55132d1 --- /dev/null +++ b/src/main/kotlin/id/walt/customTemplates/UserMetaData.kt @@ -0,0 +1,28 @@ +package model + +data class Address( + var street: String, + var houseNumber: String, + var postcode: String, + var residence: String, + var municipality: String? = null, + var country: String, + var addressType: String ? = null +) + +data class BankDetails( + var bankName: String, + var bankCode: String, + var accountNumber: String +) + +data class Insurance( + var startDate: String, + var endDate: String, + var insuranceType: String +) + +data class Telephone( + var phoneNumber: String, + var numberType: String +) \ No newline at end of file diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuableCredential.kt b/src/main/kotlin/id/walt/issuer/backend/IssuableCredential.kt new file mode 100644 index 0000000..5d1432a --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuableCredential.kt @@ -0,0 +1,53 @@ +package id.walt.issuer.backend + +import id.walt.credentials.w3c.* +import id.walt.credentials.w3c.templates.VcTemplateManager +import id.walt.customTemplates.EHIC +import id.walt.model.oidc.CredentialAuthorizationDetails +import id.walt.signatory.rest.SignatoryController + +data class IssuableCredential( + val type: String, + val credentialData: Map? = null +) { + companion object { + fun fromTemplateId(templateId: String): IssuableCredential { + + val tmpl = VcTemplateManager.getTemplate(templateId, true).template!! + + return IssuableCredential( + tmpl.type.last(), + mapOf( + Pair( + "credentialSubject", + JsonConverter.fromJsonElement(tmpl.credentialSubject!!.toJsonObject()) as Map<*, *> + ) + ) + ) + } + } +} + + +data class Issuables( + val credentials: List +) { + + /* DEPRECATED: Does not work for issuing two credentials of the same type (but with different data) + val credentialsByType + get() = credentials.associateBy { it.type } + */ + + companion object { + fun fromCredentialAuthorizationDetails(credentialDetails: List): Issuables { + return Issuables( + credentials = credentialDetails.map { IssuableCredential.fromTemplateId(it.credential_type) } + ) + } + } +} + +data class NonceResponse( + val p_nonce: String, + val expires_in: String? = null +) diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuanceSession.kt b/src/main/kotlin/id/walt/issuer/backend/IssuanceSession.kt new file mode 100644 index 0000000..70d0d8c --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuanceSession.kt @@ -0,0 +1,16 @@ +package id.walt.issuer.backend + +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import id.walt.model.oidc.CredentialAuthorizationDetails + +data class IssuanceSession( + val id: String, + val credentialDetails: List, + val nonce: String, + val isPreAuthorized: Boolean, + var authRequest: AuthorizationRequest?, + var issuables: Issuables?, + var did: String? = null, + val userPin: String? = null, + var issuerDid: String? = null +) diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuerConfig.kt b/src/main/kotlin/id/walt/issuer/backend/IssuerConfig.kt new file mode 100644 index 0000000..c14e304 --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuerConfig.kt @@ -0,0 +1,49 @@ +package id.walt.issuer.backend + +import com.beust.klaxon.Json +import com.beust.klaxon.Klaxon +import id.walt.multitenancy.TenantConfig +import id.walt.multitenancy.TenantConfigFactory +import id.walt.verifier.backend.WalletConfiguration +import id.walt.webwallet.backend.config.ExternalHostnameUrl +import id.walt.webwallet.backend.config.externalHostnameUrlValueConverter +import java.io.File + +data class IssuerConfig( + @ExternalHostnameUrl val issuerUiUrl: String = "http://localhost:8082", + @ExternalHostnameUrl val issuerApiUrl: String = "http://localhost:8080/issuer-api/default", + @Json(serializeNull = false) val issuerClientName: String = "Walt.id Issuer Portal", + val wallets: Map = WalletConfiguration.getDefaultWalletConfigurations(), + val issuerDid: String? = null +) : TenantConfig { + @Json(ignored = true) + val onboardingApiUrl + get() = issuerApiUrl.replace("/issuer-api", "/onboarding-api") + + @Json(ignored = true) + val onboardingUiUrl + get() = "$issuerUiUrl/Onboarding/" + + override fun toJson(): String { + return Klaxon().fieldConverter(ExternalHostnameUrl::class, externalHostnameUrlValueConverter).toJsonString(this) + } + + companion object : TenantConfigFactory { + + val CONFIG_FILE = "${id.walt.WALTID_DATA_ROOT}/config/issuer-config.json" + + override fun fromJson(json: String): IssuerConfig { + return Klaxon().fieldConverter(ExternalHostnameUrl::class, externalHostnameUrlValueConverter) + .parse(json) ?: IssuerConfig() + } + + override fun forDefaultTenant(): IssuerConfig { + val cf = File(CONFIG_FILE) + return if (cf.exists()) { + fromJson(cf.readText()) + } else { + IssuerConfig() + } + } + } +} diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuerController.kt b/src/main/kotlin/id/walt/issuer/backend/IssuerController.kt new file mode 100644 index 0000000..b1f4738 --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuerController.kt @@ -0,0 +1,390 @@ +package id.walt.issuer.backend + +import com.nimbusds.oauth2.sdk.* +import com.nimbusds.oauth2.sdk.http.ServletUtils +import com.nimbusds.oauth2.sdk.token.BearerAccessToken +import com.nimbusds.oauth2.sdk.token.RefreshToken +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import com.nimbusds.openid.connect.sdk.token.OIDCTokens +import id.walt.common.KlaxonWithConverters +import id.walt.credentials.w3c.toVerifiableCredential +import id.walt.model.oidc.CredentialRequest +import id.walt.model.oidc.CredentialResponse +import id.walt.multitenancy.Tenant +import id.walt.multitenancy.TenantId +import id.walt.rest.core.DidController +import id.walt.rest.core.KeyController +import id.walt.services.oidc.OIDC4CIService +import id.walt.signatory.rest.SignatoryController +import id.walt.verifier.backend.WalletConfiguration +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserInfo +import id.walt.webwallet.backend.context.WalletContextManager +import id.walt.webwallet.backend.wallet.DidCreationRequest +import id.walt.webwallet.backend.wallet.WalletController +import io.javalin.apibuilder.ApiBuilder.* +import io.javalin.http.* +import io.javalin.plugin.openapi.dsl.OpenApiDocumentation +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented +import mu.KotlinLogging +import java.net.URI + +object IssuerController { + private val logger = KotlinLogging.logger { } + val routes + get() = + path("{tenantId}") { + before { ctx -> + logger.info { "Setting issuer API context: ${ctx.pathParam("tenantId")}" } + WalletContextManager.setCurrentContext(IssuerManager.getIssuerContext(ctx.pathParam("tenantId"))) + } + after { + logger.info { "Resetting issuer API context" } + WalletContextManager.resetCurrentContext() + } + path("wallets") { + get("list", documented( + document().operation { + it.summary("List wallet configurations") + .addTagsItem("Issuer") + .operationId("listWallets") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .jsonArray("200"), + IssuerController::listWallets, + )) + } + path("config") { + fun OpenApiDocumentation.describeTenantId() = + this.run { pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } } + + path("did") { + post("create", documented(DidController.createDocs().describeTenantId(), DidController::create)) + post("createAdvanced", + documented(document().operation { + it.summary("Create new DID") + .description("Creates and registers a DID. Currently the DID methods: key, web, ebsi (v1/v2) and iota are supported. For EBSI v1: a bearer token is required.") + .operationId("createAdvanced").addTagsItem("Issuer Configuration") + .addTagsItem("Decentralized Identifiers") + } + .body() + .result("200"), + WalletController::createDid + ) + ) + post("import", documented(DidController.importDocs().describeTenantId(), DidController::import)) + get("list", documented(DidController.listDocs().describeTenantId(), DidController::list)) + post("delete", documented(DidController.deleteDocs().describeTenantId(), DidController::delete)) + } + + path("key") { + post("gen", documented(KeyController.genDocs().describeTenantId(), KeyController::gen)) + post("import", documented(KeyController.importDocs().describeTenantId(), KeyController::import)) + post("export", documented(KeyController.exportDocs().describeTenantId(), KeyController::export)) + delete("delete", documented(KeyController.deleteDocs().describeTenantId(), KeyController::delete)) + get("list", documented(KeyController.listDocs().describeTenantId(), KeyController::list)) + post("load", documented(KeyController.loadDocs().describeTenantId(), KeyController::load)) + } + + post("setConfiguration", documented(document().operation { + it.summary("Set configuration for this issuer tenant").operationId("setConfiguration") + .addTagsItem("Issuer Configuration") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .body() + .json("200"), IssuerController::setConfiguration)) + get("getConfiguration", documented(document().operation { + it.summary("Get configuration for this issuer tenant").operationId("getConfiguration") + .addTagsItem("Issuer Configuration") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .json("200"), IssuerController::getConfiguration + )) + path("templates") { + get("", documented(document().operation { + it.summary("List templates").operationId("listTemplates").addTagsItem("Issuer Configuration") + }.json>("200"), SignatoryController::listTemplates)) + get("{id}", documented(document().operation { + it.summary("Load a VC template").operationId("loadTemplate").addTagsItem("Issuer Configuration") + }.pathParam("id") { it.description("Retrieves a single VC template form the data store") } + .json("200"), SignatoryController::loadTemplate)) + post( + "{id}", documented( + document().operation { + it.summary("Import a VC template").operationId("importTemplate") + .addTagsItem("Issuer Configuration") + }.pathParam("id").body(contentType = ContentType.JSON).result("200"), + //SignatoryController::importTemplate + SignatoryController::importTemplate + ) + ) + delete("{id}", documented(document().operation { + it.summary("Remove VC template").operationId("removeTemplate").addTagsItem("Issuer Configuration") + }.pathParam("id").result("200"), SignatoryController::removeTemplate)) + + } + } + path("credentials") { + get("listIssuables", documented( + document().operation { + it.summary("List issuable credentials") + .addTagsItem("Issuer") + .operationId("listIssuableCredentials") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("sessionId") + .json("200"), + IssuerController::listIssuableCredentials)) + path("issuance") { + post("request", documented( + document().operation { + it.summary("Request issuance of selected credentials to wallet") + .addTagsItem("Issuer") + .operationId("requestIssuance") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("walletId") + .queryParam("sessionId") + .queryParam("isPreAuthorized") + .queryParam("userPin") + .queryParam("issuerDid") + .body() + .result("200"), + IssuerController::requestIssuance + )) + } + } + path("oidc") { + get(".well-known/openid-configuration", documented( + document().operation { + it.summary("get OIDC provider meta data") + .addTagsItem("Issuer") + .operationId("oidcProviderMeta") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .json("200"), + IssuerController::oidcProviderMeta + )) + get(".well-known/openid-credential-issuer", documented( + document().operation { + it.summary("get OIDC provider meta data") + .addTagsItem("Issuer") + .operationId("oidcProviderMeta") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .json("200"), + IssuerController::oidcProviderMeta + )) + post("par", documented( + document().operation { + it.summary("pushed authorization request") + .addTagsItem("Issuer") + .operationId("par") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .formParam("response_type") + .formParam("client_id") + .formParam("redirect_uri") + .formParam("scope") + .formParam("claims") + .formParam("state") + .formParam("op_state") + .json("201"), + IssuerController::par + )) + get("fulfillPAR", documented( + document().operation { it.summary("fulfill PAR").addTagsItem("Issuer").operationId("fulfillPAR") } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("request_uri"), + IssuerController::fulfillPAR + )) + post("token", documented( + document().operation { + it.summary("token endpoint") + .addTagsItem("Issuer") + .operationId("token") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .formParam("grant_type") + .formParam("code") + .formParam("pre-authorized_code") + .formParam("redirect_uri") + .formParam("user_pin") + .formParam("code_verifier") + .json("200"), + IssuerController::token + )) + post("credential", documented( + document().operation { + it.summary("Credential endpoint").operationId("credential").addTagsItem("Issuer") + } + .header("Authorization") + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .body() + .json("200"), + IssuerController::credential + )) + } + } + + private fun getConfiguration(context: Context) { + + try { + context.json(IssuerTenant.config) + } catch (nfe: Tenant.TenantNotFoundException) { + throw NotFoundResponse() + } + } + + private fun setConfiguration(context: Context) { + val config = context.bodyAsClass() + IssuerTenant.setConfig(config) + } + + fun listWallets(ctx: Context) { + ctx.json(IssuerTenant.config.wallets.values) + } + + fun listIssuableCredentials(ctx: Context) { + val sessionId = ctx.queryParam("sessionId") + + if (sessionId == null) + ctx.json(IssuerManager.listIssuableCredentials()) + else + ctx.json(IssuerManager.getIssuanceSession(sessionId)?.issuables ?: Issuables(credentials = listOf())) + } + + fun requestIssuance(ctx: Context) { + val wallet = ctx.queryParam("walletId")?.let { IssuerTenant.config.wallets.getOrDefault(it, null) } + ?: IssuerManager.getXDeviceWallet() + val session = ctx.queryParam("sessionId")?.let { IssuerManager.getIssuanceSession(it) } + val issuerDid = ctx.queryParam("issuerDid") // OPTIONAL + + val selectedIssuables = ctx.bodyAsClass() + selectedIssuables.credentials.get(0).credentialData?.forEach { it -> print("key: ${it.key} \t value: ${it.value}") } + if (selectedIssuables.credentials.isEmpty()) { + ctx.status(HttpCode.BAD_REQUEST).result("No issuable credential selected") + return + } + + if (session != null) { + val authRequest = session.authRequest ?: throw BadRequestResponse("No authorization request found for this session") + IssuerManager.updateIssuanceSession(session, selectedIssuables, issuerDid) + ctx.result("${authRequest.redirectionURI}?code=${IssuerManager.generateAuthorizationCodeFor(session)}&state=${authRequest.state.value}") + } else { + val userPin = ctx.queryParam("userPin")?.ifBlank { null } + val isPreAuthorized = ctx.queryParam("isPreAuthorized")?.toBoolean() ?: false + val initiationRequest = + IssuerManager.newIssuanceInitiationRequest(selectedIssuables, isPreAuthorized, userPin, issuerDid) + ctx.result("${wallet.url}${if (!wallet.url.endsWith("/")) "/" else ""}${wallet.receivePath}?${initiationRequest.toQueryString()}") + } + } + + fun oidcProviderMeta(ctx: Context) { + ctx.json(IssuerManager.getOidcProviderMetadata().toJSONObject()) + } + + fun par(ctx: Context) { + val req = AuthorizationRequest.parse(ServletUtils.createHTTPRequest(ctx.req)) + val session = if (req.customParameters.containsKey("op_state")) { + IssuerManager.getIssuanceSession(req.customParameters["op_state"]!!.first())?.apply { + authRequest = req + IssuerManager.updateIssuanceSession(this, issuables) + } + } else { + val authDetails = OIDC4CIService.getCredentialAuthorizationDetails(req) + if (authDetails.isEmpty()) { + ctx.status(HttpCode.BAD_REQUEST) + .json( + PushedAuthorizationErrorResponse( + ErrorObject( + "400", + "No credential authorization details given", + 400 + ) + ) + ) + return + } + authDetails.forEach { it -> println("format: ${it.format} credential type: ${it.credential_type} TYPE: ${it.type} LOCATIONS: ${it.locations.toString()}" ) } + + IssuerManager.initializeIssuanceSession(authDetails, preAuthorized = false, req) + } ?: throw BadRequestResponse("Session given by op_state not found") + ctx.status(HttpCode.CREATED).json( + PushedAuthorizationSuccessResponse( + URI("urn:ietf:params:oauth:request_uri:${session.id}"), + IssuerState.EXPIRATION_TIME.seconds + ).toJSONObject() + ) + } + + fun fulfillPAR(ctx: Context) { + val parURI = ctx.queryParam("request_uri")!! + val sessionID = parURI.substringAfterLast("urn:ietf:params:oauth:request_uri:") + val session = IssuerManager.getIssuanceSession(sessionID) + if (session != null) { + ctx.status(HttpCode.FOUND).header("Location", "${IssuerTenant.config.issuerUiUrl}/?sessionId=${session.id}") + } else { + ctx.status(HttpCode.FOUND) + .header("Location", "${IssuerTenant.config.issuerUiUrl}/IssuanceError?message=Invalid issuance session") + } + } + + fun token(ctx: Context) { + val tokenReq = TokenRequest.parse(ServletUtils.createHTTPRequest(ctx.req)) + val code = when (tokenReq.authorizationGrant.type) { + GrantType.AUTHORIZATION_CODE -> (tokenReq.authorizationGrant as AuthorizationCodeGrant).authorizationCode + PreAuthorizedCodeGrant.GRANT_TYPE -> (tokenReq.authorizationGrant as PreAuthorizedCodeGrant).code + else -> throw BadRequestResponse("Unsupported grant type") + } + val sessionId = IssuerManager.validateAuthorizationCode(code.value) + val session = IssuerManager.getIssuanceSession(sessionId) + if (session == null) { + ctx.status(HttpCode.NOT_FOUND).json(TokenErrorResponse(OAuth2Error.INVALID_REQUEST).toJSONObject()) + return + } + if (tokenReq.authorizationGrant.type == PreAuthorizedCodeGrant.GRANT_TYPE) { + val pinMatches = + session.userPin?.let { it == (tokenReq.authorizationGrant as PreAuthorizedCodeGrant).userPin } ?: true + if (!pinMatches) { + throw ForbiddenResponse("User PIN required") + } + } + + ctx.json( + OIDCTokenResponse( + OIDCTokens(JWTService.toJWT(UserInfo(session.id)), BearerAccessToken(session.id), RefreshToken()), mapOf( + "expires_in" to IssuerState.EXPIRATION_TIME.seconds, + "c_nonce" to session.nonce + ) + ).toJSONObject() + ) + } + + fun credential(ctx: Context) { + val session = ctx.header("Authorization")?.substringAfterLast("Bearer ") + ?.let { IssuerManager.getIssuanceSession(it) } + ?: throw ForbiddenResponse("Invalid or unknown access token") + + val credentialRequest = + KlaxonWithConverters().parse(ctx.body()) + ?: throw BadRequestResponse("Could not parse credential request body") + + val credential = IssuerManager.fulfillIssuanceSession(session, credentialRequest) + if (credential.isNullOrEmpty()) { + ctx.status(HttpCode.NOT_FOUND).result("No issuable credential with the given type found") + return + } + val credObj = credential.toVerifiableCredential() + ctx.contentType(ContentType.JSON).result( + KlaxonWithConverters().toJsonString( + CredentialResponse( + if (credObj.jwt != null) "jwt_vc" else "ldp_vc", + credential.toVerifiableCredential() + ) + ) + ) + } +} diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuerManager.kt b/src/main/kotlin/id/walt/issuer/backend/IssuerManager.kt new file mode 100644 index 0000000..2703009 --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuerManager.kt @@ -0,0 +1,285 @@ +package id.walt.issuer.backend + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import com.nimbusds.oauth2.sdk.GrantType +import com.nimbusds.oauth2.sdk.PreAuthorizedCodeGrant +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.openid.connect.sdk.SubjectType +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.credentials.w3c.templates.VcTemplateManager +import id.walt.crypto.LdSignatureType +import id.walt.customTemplates.EHIC +import id.walt.model.DidMethod +import id.walt.model.DidUrl +import id.walt.model.oidc.* +import id.walt.multitenancy.TenantContext +import id.walt.multitenancy.TenantContextManager +import id.walt.multitenancy.TenantId +import id.walt.multitenancy.TenantType +import id.walt.services.did.DidService +import id.walt.services.jwt.JwtService +import id.walt.signatory.ProofConfig +import id.walt.signatory.ProofType +import id.walt.signatory.Signatory +import id.walt.signatory.dataproviders.MergingDataProvider +import id.walt.verifier.backend.WalletConfiguration +import io.github.pavleprica.kotlin.cache.time.based.shortTimeBasedCache +import io.javalin.http.BadRequestResponse +import mu.KotlinLogging +import java.net.URI +import java.time.Instant +import java.util.* + +const val URL_PATTERN = "^https?:\\/\\/(?!-.)[^\\s\\/\$.?#].[^\\s]*\$" +fun isSchema(typeOrSchema: String): Boolean { + return Regex(URL_PATTERN).matches(typeOrSchema) +} + +object IssuerManager { + val log = KotlinLogging.logger { } + val defaultDid: String + get() = IssuerTenant.config.issuerDid + ?: IssuerTenant.state.defaultDid + ?: DidService.create(DidMethod.key) + .also { + IssuerTenant.state.defaultDid = it + log.warn { "No issuer DID configured, created temporary did:key for issuing: $it" } + } + + fun getIssuerContext(tenantId: String): TenantContext { + return TenantContextManager.getTenantContext(TenantId(TenantType.ISSUER, tenantId)) { IssuerState() } + } + + fun listIssuableCredentials(): Issuables { + return Issuables( + credentials = listOf( + "VerifiableId", + "VerifiableDiploma", + "VerifiableVaccinationCertificate", + "ProofOfResidence", + "ParticipantCredential", + "Europass", + "OpenBadgeCredential" + ) + .map { IssuableCredential.fromTemplateId(it) } + ) + } + + private fun prompt(prompt: String, default: String?): String? { + print("$prompt [$default]: ") + val input = readlnOrNull() + return when (input.isNullOrBlank()) { + true -> default + else -> input + } + } + + fun getValidNonces(): Set { + return IssuerTenant.state.nonceCache.asMap().keys + } + + fun newIssuanceInitiationRequest( + selectedIssuables: Issuables, + preAuthorized: Boolean, + userPin: String? = null, + issuerDid: String? = null + ): IssuanceInitiationRequest { + val issuerUri = URI.create("${IssuerTenant.config.issuerApiUrl}/oidc/") + val session = initializeIssuanceSession( + credentialDetails = selectedIssuables.credentials.map { issuable -> + CredentialAuthorizationDetails(issuable.type) + }, + preAuthorized = preAuthorized, + authRequest = null, + userPin = userPin, + issuerDid = issuerDid + ) + updateIssuanceSession(session, selectedIssuables, issuerDid) + + return IssuanceInitiationRequest( + issuer_url = issuerUri.toString(), + credential_types = selectedIssuables.credentials.map { it.type }, + pre_authorized_code = if (preAuthorized) generateAuthorizationCodeFor(session) else null, + user_pin_required = userPin != null, + op_state = if (!preAuthorized) session.id else null + ) + } + + fun initializeIssuanceSession( + credentialDetails: List, + preAuthorized: Boolean, + authRequest: AuthorizationRequest?, + userPin: String? = null, + issuerDid: String? = null + ): IssuanceSession { + val id = UUID.randomUUID().toString() + //TODO: validata/verify PAR request, claims, etc + val session = IssuanceSession( + id, + credentialDetails, + UUID.randomUUID().toString(), + isPreAuthorized = preAuthorized, + authRequest, + Issuables.fromCredentialAuthorizationDetails(credentialDetails), + userPin = userPin, + issuerDid = issuerDid + ) + IssuerTenant.state.sessionCache.put(id, session) + return session + } + + fun getIssuanceSession(id: String): IssuanceSession? { + return IssuerTenant.state.sessionCache.getIfPresent(id) + } + + fun updateIssuanceSession(session: IssuanceSession, issuables: Issuables?, issuerDid: String? = null) { + session.issuables = issuables + issuerDid?.let { session.issuerDid = issuerDid } + IssuerTenant.state.sessionCache.put(session.id, session) + } + + fun generateAuthorizationCodeFor(session: IssuanceSession): String { + return IssuerTenant.state.authCodeProvider.generateToken(session) + } + + fun validateAuthorizationCode(code: String): String { + return IssuerTenant.state.authCodeProvider.validateToken(code).map { it.subject } + .orElseThrow { BadRequestResponse("Invalid authorization code given") } + } + + private inline fun Iterable.allUniqueBy(transform: (T) -> R) = + HashSet().let { hs -> + all { hs.add(transform(it)) } + } + + /** + * For multipleCredentialsOfSameType in session.issuables + */ + private val sessionAccessCounterCache = shortTimeBasedCache>() + fun fulfillIssuanceSession(session: IssuanceSession, credentialRequest: CredentialRequest): String? { + val proof = credentialRequest.proof ?: throw BadRequestResponse("No proof given") + val parsedJwt = SignedJWT.parse(proof.jwt) + if (parsedJwt.header.keyID?.let { DidUrl.isDidUrl(it) } == false) throw BadRequestResponse("Proof is not DID signed") + + if (!JwtService.getService().verify(proof.jwt)) throw BadRequestResponse("Proof invalid") + + val did = DidUrl.from(parsedJwt.header.keyID).did + val now = Instant.now() + val issuables = session.issuables ?: throw BadRequestResponse("No issuables") + + log.debug { "Issuance session ${session.id}: Session issuables: ${session.issuables}" } + + val sessionLongId = "${session.id}${session.nonce}" + + + val multipleCredentialsOfSameType = !issuables.credentials.allUniqueBy { it.type } + + if (multipleCredentialsOfSameType && sessionAccessCounterCache[sessionLongId].isEmpty) { + log.debug { "Issuance session ${session.id}: Setup multipleCredentialsOfSameType" } + sessionAccessCounterCache[sessionLongId] = HashMap() + } + + val requestedType = credentialRequest.type + val credentialsOfRequestedType = issuables.credentials.filter { it.type == requestedType } + + val credential = when { + !multipleCredentialsOfSameType -> credentialsOfRequestedType.firstOrNull() + else -> { + val accessCounter = sessionAccessCounterCache[sessionLongId].get() + + if (!accessCounter.contains(requestedType)) + accessCounter[requestedType] = -1 + + accessCounter[requestedType] = accessCounter[requestedType]!! + 1 + + log.info { + "Issuance session ${session.id}: multipleCredentialsOfSameType " + + "request ${accessCounter[requestedType]!! + 1}/${issuables.credentials.size}" + } + + credentialsOfRequestedType.getOrElse(accessCounter[requestedType]!!) { credentialsOfRequestedType.lastOrNull() } + } + } + + return credential?.let { + Signatory.getService().issue(it.type, + ProofConfig( + issuerDid = session.issuerDid ?: defaultDid, + proofType = when (credentialRequest.format) { + "jwt_vc" -> ProofType.JWT + else -> ProofType.LD_PROOF + }, + subjectDid = did, + issueDate = now, + validDate = now + ), + dataProvider = it.credentialData?.let { cd -> MergingDataProvider(cd) }) + } + } + + fun getXDeviceWallet(): WalletConfiguration { + return WalletConfiguration( + id = "x-device", + url = "openid-initiate-issuance://", + presentPath = "", + receivePath = "", + description = "cross device" + ) + } + + fun getOidcProviderMetadata() = OIDCProviderMetadata( + Issuer(IssuerTenant.config.issuerApiUrl), + listOf(SubjectType.PUBLIC), + URI("${IssuerTenant.config.issuerApiUrl}/oidc") + ).apply { + authorizationEndpointURI = URI("${IssuerTenant.config.issuerApiUrl}/oidc/fulfillPAR") + pushedAuthorizationRequestEndpointURI = URI("${IssuerTenant.config.issuerApiUrl}/oidc/par") + tokenEndpointURI = URI("${IssuerTenant.config.issuerApiUrl}/oidc/token") + grantTypes = listOf(GrantType.AUTHORIZATION_CODE, PreAuthorizedCodeGrant.GRANT_TYPE) + setCustomParameter("credential_endpoint", "${IssuerTenant.config.issuerApiUrl}/oidc/credential") + setCustomParameter( + "credential_issuer", CredentialIssuer( + listOf( + CredentialIssuerDisplay(IssuerTenant.config.issuerApiUrl) + ) + ) + ) + // Inject custom made Verifiable Credential + VcTemplateManager.register("EuropeanHealthInsuranceCard", VerifiableCredential.fromString(EHIC.template!!.invoke().encode())) + setCustomParameter( + "credentials_supported", + VcTemplateManager.listTemplates().map { + VcTemplateManager.getTemplate(it.name, true) } + .associateBy({ tmpl -> tmpl.template!!.type.last() }) { cred -> + CredentialMetadata( + formats = mapOf( + "ldp_vc" to CredentialFormat( + types = cred.template!!.type, + cryptographic_binding_methods_supported = listOf("did"), + cryptographic_suites_supported = LdSignatureType.values().map { it.name } + ), + "jwt_vc" to CredentialFormat( + types = cred.template!!.type, + cryptographic_binding_methods_supported = listOf("did"), + cryptographic_suites_supported = listOf( + JWSAlgorithm.ES256, + JWSAlgorithm.ES256K, + JWSAlgorithm.EdDSA, + JWSAlgorithm.RS256, + JWSAlgorithm.PS256 + ).map { it.name } + ) + ), + display = listOf( + CredentialDisplay( + name = cred.template!!.type.last() + ) + ) + ) + } + ) + } +} diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuerState.kt b/src/main/kotlin/id/walt/issuer/backend/IssuerState.kt new file mode 100644 index 0000000..bae6fdd --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuerState.kt @@ -0,0 +1,35 @@ +package id.walt.issuer.backend + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.google.common.cache.CacheBuilder +import id.walt.multitenancy.TenantState +import javalinjwt.JWTProvider +import java.time.Duration +import java.util.* +import java.util.concurrent.* + +class IssuerState : TenantState { + val nonceCache = + CacheBuilder.newBuilder().expireAfterWrite(EXPIRATION_TIME.seconds, TimeUnit.SECONDS).build() + val sessionCache = + CacheBuilder.newBuilder().expireAfterAccess(EXPIRATION_TIME.seconds, TimeUnit.SECONDS).build() + + val authCodeSecret = System.getenv("WALTID_ISSUER_AUTH_CODE_SECRET") ?: UUID.randomUUID().toString() + val algorithm: Algorithm = Algorithm.HMAC256(authCodeSecret) + + val authCodeProvider = JWTProvider( + algorithm, + { session: IssuanceSession, alg: Algorithm? -> + JWT.create().withSubject(session.id).withClaim("pre-authorized", session.isPreAuthorized).sign(alg) + }, + JWT.require(algorithm).build() + ) + var defaultDid: String? = null + + companion object { + val EXPIRATION_TIME: Duration = Duration.ofMinutes(5) + } + + override var config: IssuerConfig? = null +} diff --git a/src/main/kotlin/id/walt/issuer/backend/IssuerTenant.kt b/src/main/kotlin/id/walt/issuer/backend/IssuerTenant.kt new file mode 100644 index 0000000..c36fe5a --- /dev/null +++ b/src/main/kotlin/id/walt/issuer/backend/IssuerTenant.kt @@ -0,0 +1,5 @@ +package id.walt.issuer.backend + +import id.walt.multitenancy.Tenant + +object IssuerTenant : Tenant(IssuerConfig) diff --git a/src/main/kotlin/id/walt/multitenancy/MultitenancyController.kt b/src/main/kotlin/id/walt/multitenancy/MultitenancyController.kt new file mode 100644 index 0000000..d928266 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/MultitenancyController.kt @@ -0,0 +1,52 @@ +package id.walt.multitenancy + +import id.walt.common.KlaxonWithConverters +import io.javalin.apibuilder.ApiBuilder.get +import io.javalin.apibuilder.ApiBuilder.path +import io.javalin.http.ContentType +import io.javalin.http.Context +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented + +object MultitenancyController { + val routes + get() = path("multitenancy") { + get( + "listLoadedTenants", documented( + document().operation { + it.summary("List multitenancy *LOADED* tenants. If no tenants are loaded (e.g. right after a restart) this method will indeed return an empty list.") + .addTagsItem("Multitenancy") + .operationId("listLoadedTenants") + }.json>("200"), + MultitenancyController::listLoadedTenants + ) + ) + + get( + "listAllTenantIdsByType/{TenantType}", documented( + document().operation { + it.summary( + "List multitenancy tenant IDs by tenant type. Available tenant types: ${ + TenantType.values().map { it.name } + }" + ) + .addTagsItem("Multitenancy") + .operationId("listAllTenantIdsByType") + }.json>("200"), + MultitenancyController::listAllTenantIdsByType + ) + ) + } + + private fun listLoadedTenants(ctx: Context) { + val contextsJson = TenantContextManager.listLoadedContexts() + val json = KlaxonWithConverters().toJsonString(contextsJson) + + ctx.status(200).result(json).contentType(ContentType.APPLICATION_JSON) + } + + private fun listAllTenantIdsByType(ctx: Context) { + val tenantType = ctx.pathParam("TenantType") + ctx.json(TenantContextManager.listAllContextIdsByType(TenantType.valueOf(tenantType))) + } +} diff --git a/src/main/kotlin/id/walt/multitenancy/Tenant.kt b/src/main/kotlin/id/walt/multitenancy/Tenant.kt new file mode 100644 index 0000000..457f8c3 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/Tenant.kt @@ -0,0 +1,71 @@ +package id.walt.multitenancy + +import id.walt.services.context.ContextManager +import id.walt.services.hkvstore.HKVKey +import id.walt.webwallet.backend.context.UserContext +import id.walt.webwallet.backend.context.WalletContextManager +import mu.KotlinLogging +import kotlin.reflect.jvm.jvmName + +abstract class Tenant>(private val configFactory: TenantConfigFactory) { + class TenantNotFoundException(message: String) : Exception(message) + + private val log = KotlinLogging.logger { } + + + val CONFIG_KEY = "config" + + + private fun waltContextStuffErrorsAgain(type: String, extra: String? = null): Nothing = throw WaltContextTenantSystemError( + "WaltContext system does not work (again)... " + + "Current context \"${WalletContextManager.currentContext::class.jvmName}\" was casted to TenantContext, but is ${ + when (type) { + "otherClass" -> "a different class" + "wrongGenericType" -> "a TenantContext, but of different generics types" + else -> "UNKNOWN ERROR" + } + }${if (extra == null) "" else " $extra"}" + ) + + data class WaltContextTenantSystemError(override val message: String): Exception() + + val context: TenantContext + get() { + val currentContext = WalletContextManager.currentContext + + if (currentContext is UserContext) throw IllegalArgumentException("You are authenticated with a user context (authenticated using a user bearer token), but are probably accessing an endpoint meant for tenant contexts (set with {tenantId} in the URL). If you try to use a tenant context method, do not set a user context at the same time, leave the header 'Authentication: Bearer ' from your request.") + + try { + return currentContext as? TenantContext ?: waltContextStuffErrorsAgain("wrongGenericType") + } catch (e: Exception) { + log.debug { "Current context: ${currentContext::class.simpleName}: $currentContext" } + + when { + e is WaltContextTenantSystemError -> throw e + currentContext !is TenantContext<*, *> -> waltContextStuffErrorsAgain("otherClass", e.message) + else -> throw WaltContextTenantSystemError("Context/Tenant system error: ${e.message}") + } + } + } + + val tenantId: TenantId + get() = context.tenantId + + val config: C + get() = context.state.config ?: ContextManager.hkvStore.getAsString(HKVKey(CONFIG_KEY)) + ?.let { configFactory.fromJson(it) } ?: if (context.tenantId.id == TenantId.DEFAULT_TENANT) { + configFactory.forDefaultTenant() + } else { + throw TenantNotFoundException("Tenant config not found") + }.also { + context.state.config = it + } + + val state: S + get() = context.state + + fun setConfig(config: C) { + context.state.config = config + ContextManager.hkvStore.put(HKVKey(CONFIG_KEY), config.toJson()) + } +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantCmd.kt b/src/main/kotlin/id/walt/multitenancy/TenantCmd.kt new file mode 100644 index 0000000..6d23c73 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantCmd.kt @@ -0,0 +1,31 @@ +package id.walt.multitenancy + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import id.walt.issuer.backend.IssuerConfig +import id.walt.issuer.backend.IssuerTenant +import id.walt.webwallet.backend.context.WalletContextManager +import java.io.File + +class TenantCmd : CliktCommand(name = "tenant", help = "Manage tenant for this issuer or verifier") { + + override fun run() { + } +} + +class ConfigureTenantCmd : CliktCommand(name = "configure", help = "Configure current issuer or verifier tenant") { + val config: String by argument("config", help = "Path to config file for this tenant") + + override fun run() { + val configFile = File(config) + if (!configFile.exists()) { + throw Exception("Config file not found") + } + + val tenantContext = WalletContextManager.currentContext as TenantContext<*, *> + when (tenantContext.tenantId.type) { + TenantType.ISSUER -> IssuerTenant.setConfig(IssuerConfig.fromJson(configFile.readText())) + else -> throw IllegalArgumentException("Tenant type not yet supported") + } + } +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantConfig.kt b/src/main/kotlin/id/walt/multitenancy/TenantConfig.kt new file mode 100644 index 0000000..939063c --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantConfig.kt @@ -0,0 +1,5 @@ +package id.walt.multitenancy + +interface TenantConfig { + fun toJson(): String +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantConfigFactory.kt b/src/main/kotlin/id/walt/multitenancy/TenantConfigFactory.kt new file mode 100644 index 0000000..c7ce10e --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantConfigFactory.kt @@ -0,0 +1,7 @@ +package id.walt.multitenancy + + +interface TenantConfigFactory { + fun fromJson(json: String): C + fun forDefaultTenant(): C +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantContext.kt b/src/main/kotlin/id/walt/multitenancy/TenantContext.kt new file mode 100644 index 0000000..14c30b8 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantContext.kt @@ -0,0 +1,16 @@ +package id.walt.multitenancy + +import id.walt.services.context.Context +import id.walt.services.hkvstore.HKVStoreService +import id.walt.services.keystore.KeyStoreService +import id.walt.services.vcstore.VcStoreService + +class TenantContext>( + val tenantId: TenantId, + override val hkvStore: HKVStoreService, + override val keyStore: KeyStoreService, + override val vcStore: VcStoreService, + val state: S +) : Context { + override fun toString() = tenantId.toString() +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantContextManager.kt b/src/main/kotlin/id/walt/multitenancy/TenantContextManager.kt new file mode 100644 index 0000000..ae23e7f --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantContextManager.kt @@ -0,0 +1,43 @@ +package id.walt.multitenancy + +import id.walt.WALTID_DATA_ROOT +import id.walt.common.prettyPrint +import id.walt.services.hkvstore.FileSystemHKVStore +import id.walt.services.hkvstore.FilesystemStoreConfig +import id.walt.services.keystore.HKVKeyStoreService +import id.walt.services.vcstore.HKVVcStoreService +import io.javalin.core.util.RouteOverviewUtil.metaInfo +import kotlin.io.path.Path +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +object TenantContextManager { + private val contexts: MutableMap> = mutableMapOf() + + // TODO: make context data stores configurable + + fun listLoadedContexts(): List { + return listOf( + //IssuerManager.getIssuerContext(TenantId.DEFAULT_TENANT).tenantId, // Preload issuer context + //VerifierManager.getService().getVerifierContext(TenantId.DEFAULT_TENANT).tenantId, // Preload verifier context + *contexts.values.map { it.tenantId }.toTypedArray(), + ).distinct() + } + + fun listAllContextIdsByType(tenantType: TenantType) = Path("$WALTID_DATA_ROOT/data/tenants/${tenantType}/") + .listDirectoryEntries() + .map { it.name } + + fun > getTenantContext(tenantId: TenantId, createState: () -> S): TenantContext { + // TODO: create tenant context according to context configuration + return contexts[tenantId.toString()] as? TenantContext ?: TenantContext( + tenantId = tenantId, + hkvStore = FileSystemHKVStore(FilesystemStoreConfig("$WALTID_DATA_ROOT/data/tenants/${tenantId.type}/${tenantId.id}")), + keyStore = HKVKeyStoreService(), + vcStore = HKVVcStoreService(), + state = createState() + ).also { + contexts[tenantId.toString()] = it + } + } +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantId.kt b/src/main/kotlin/id/walt/multitenancy/TenantId.kt new file mode 100644 index 0000000..df8a816 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantId.kt @@ -0,0 +1,15 @@ +package id.walt.multitenancy + +data class TenantId(val type: TenantType, val id: String) { + override fun toString(): String { + return "$type/$id" + } + + override fun equals(other: Any?): Boolean { + return other is TenantId && type == other.type && id == other.id + } + + companion object { + const val DEFAULT_TENANT = "default" + } +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantState.kt b/src/main/kotlin/id/walt/multitenancy/TenantState.kt new file mode 100644 index 0000000..d10bee6 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantState.kt @@ -0,0 +1,5 @@ +package id.walt.multitenancy + +interface TenantState { + var config: C? +} diff --git a/src/main/kotlin/id/walt/multitenancy/TenantType.kt b/src/main/kotlin/id/walt/multitenancy/TenantType.kt new file mode 100644 index 0000000..6686ed6 --- /dev/null +++ b/src/main/kotlin/id/walt/multitenancy/TenantType.kt @@ -0,0 +1,6 @@ +package id.walt.multitenancy + +enum class TenantType { + ISSUER, + VERIFIER, +} diff --git a/src/main/kotlin/id/walt/onboarding/backend/DomainOwnershipService.kt b/src/main/kotlin/id/walt/onboarding/backend/DomainOwnershipService.kt new file mode 100644 index 0000000..397aa46 --- /dev/null +++ b/src/main/kotlin/id/walt/onboarding/backend/DomainOwnershipService.kt @@ -0,0 +1,37 @@ +package id.walt.onboarding.backend + +import id.walt.crypto.toHexString +import java.security.MessageDigest +import java.util.* +import javax.naming.Context +import javax.naming.directory.DirContext +import javax.naming.directory.InitialDirContext + +object DomainOwnershipService { + + /** + * Unique verification code for each domain and DID + */ + fun generateWaltIdDomainVerificationCode(domain: String, did: String): String = + "ssi-onboarding-verification=" + MessageDigest.getInstance("SHA-1") + .digest(domain.plus(did).toByteArray()).toHexString().replace(" ", "") + + fun checkWaltIdDomainVerificationCode(domain: String, did: String): Boolean = + checkDomainVerificationCode(domain, generateWaltIdDomainVerificationCode(domain, did)) + + fun checkDomainVerificationCode(domain: String, code: String): Boolean { + println("Checking domain: $domain (code: $code)") + val env: Hashtable = Hashtable() + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory") + env.put(Context.PROVIDER_URL, "dns:") + + val ctx: DirContext = InitialDirContext(env) + val attributes = ctx.getAttributes(domain, arrayOf("TXT")).all + while (attributes.hasMore()) { + if (attributes.next().contains(code)) { + return true + } + } + return false + } +} diff --git a/src/main/kotlin/id/walt/onboarding/backend/OnboardingController.kt b/src/main/kotlin/id/walt/onboarding/backend/OnboardingController.kt new file mode 100644 index 0000000..62cfbde --- /dev/null +++ b/src/main/kotlin/id/walt/onboarding/backend/OnboardingController.kt @@ -0,0 +1,278 @@ +package id.walt.onboarding.backend + +import com.beust.klaxon.Klaxon +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.openid.connect.sdk.SubjectType +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import id.walt.auditor.Auditor +import id.walt.auditor.ChallengePolicy +import id.walt.auditor.ChallengePolicyArg +import id.walt.auditor.SignaturePolicy +import id.walt.issuer.backend.IssuableCredential +import id.walt.issuer.backend.Issuables +import id.walt.issuer.backend.IssuerManager +import id.walt.issuer.backend.IssuerTenant +import id.walt.model.DidMethod +import id.walt.model.DidUrl +import id.walt.model.dif.CredentialManifest +import id.walt.model.dif.OutputDescriptor +import id.walt.model.dif.PresentationDefinition +import id.walt.multitenancy.TenantId +import id.walt.services.jwt.JwtService +import id.walt.services.oidc.OIDCUtils +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserInfo +import id.walt.webwallet.backend.auth.UserRole +import id.walt.webwallet.backend.context.WalletContextManager +import io.javalin.apibuilder.ApiBuilder.* +import io.javalin.http.* +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented +import java.net.URI + +data class GenerateDomainVerificationCodeRequest(val domain: String) +data class CheckDomainVerificationCodeRequest(val domain: String) +data class IssueParticipantCredentialRequest(val domain: String) + +object OnboardingController { + val routes + get() = + path("") { + before { + WalletContextManager.setCurrentContext(IssuerManager.getIssuerContext(TenantId.DEFAULT_TENANT)) + } + after { + WalletContextManager.resetCurrentContext() + } + path("domain") { + path("generateDomainVerificationCode") { + post("", documented( + document().operation { + it.summary("Generate domain verification code") + .addTagsItem("Onboarding") + .operationId("generateDomainVerificationCode") + } + .body() + .result("200"), + OnboardingController::generateDomainVerificationCode + ), UserRole.AUTHORIZED) + } + path("checkDomainVerificationCode") { + post("", documented( + document().operation { + it.summary("Check domain verification code") + .addTagsItem("Onboarding") + .operationId("checkDomainVerificationCode") + } + .body() + .result("200"), + OnboardingController::checkDomainVerificationCode + ), UserRole.AUTHORIZED) + } + } + // provide customized oidc discovery document and authorize endpoint + path("oidc") { + get(".well-known/openid-configuration", documented( + document().operation { + it.summary("get OIDC provider meta data") + .addTagsItem("Onboarding") + .operationId("ob-oidcProviderMeta") + } + .json("200"), + OnboardingController::oidcProviderMeta + )) + get("fulfillPAR", documented( + document().operation { + it.summary("fulfill PAR").addTagsItem("Onboarding").operationId("fulfillPAR") + } + .queryParam("request_uri"), + OnboardingController::fulfillPAR + )) + } + path("auth") { + get( + "userToken", documented( + document().operation { + it.summary("get user token").addTagsItem("Onboarding").operationId("userToken") + }.json("200"), + OnboardingController::userToken + ) + ) + } + post( + "issue", + documented(document().operation { + it.summary("Issue participant credential to did:web-authorized user").addTagsItem("Onboarding") + } + .queryParam("sessionId").body(), + OnboardingController::issue), + UserRole.AUTHORIZED) + } + + private fun generateDomainVerificationCode(ctx: Context) { + val did = checkAuthDid(ctx) ?: return + val domainReq = ctx.bodyAsClass() + ctx.result(DomainOwnershipService.generateWaltIdDomainVerificationCode(domainReq.domain, did)) + } + + private fun checkDomainVerificationCode(ctx: Context) { + val did = checkAuthDid(ctx) ?: return + val domainReq = ctx.bodyAsClass() + ctx.json(DomainOwnershipService.checkWaltIdDomainVerificationCode(domainReq.domain, did)) + } + + private fun checkAuthDid(ctx: Context): String? { + val userInfo = JWTService.getUserInfo(ctx) + if (userInfo == null) { + ctx.status(HttpCode.UNAUTHORIZED) + return null + } else if (userInfo.did == null) { + ctx.result("An authenticated DID is required for accessing this API") + ctx.status(HttpCode.UNAUTHORIZED) + return null + } + return userInfo.did!! + } + + const val PARICIPANT_CREDENTIAL_SCHEMA_ID = + "https://raw.githubusercontent.com/walt-id/waltid-ssikit-vclib/master/src/test/resources/schemas/ParticipantCredential.json" + + private fun oidcProviderMeta(ctx: Context) { + ctx.json( + OIDCProviderMetadata( + Issuer(IssuerTenant.config.onboardingApiUrl), + listOf(SubjectType.PAIRWISE, SubjectType.PUBLIC), + URI("http://blank") + ).apply { + authorizationEndpointURI = URI("${IssuerTenant.config.onboardingApiUrl}/oidc/fulfillPAR") + pushedAuthorizationRequestEndpointURI = URI("${IssuerTenant.config.issuerApiUrl}/oidc/par") // keep issuer-api + tokenEndpointURI = URI("${IssuerTenant.config.issuerApiUrl}/oidc/token") // keep issuer-api + setCustomParameter( + "credential_endpoint", + "${IssuerTenant.config.issuerApiUrl}/oidc/credential" + ) // keep issuer-api + setCustomParameter("nonce_endpoint", "${IssuerTenant.config.issuerApiUrl}/oidc/nonce") // keep issuer-api + setCustomParameter("credential_manifests", listOf( + CredentialManifest( + issuer = id.walt.model.dif.Issuer(IssuerManager.defaultDid, IssuerTenant.config.issuerClientName), + outputDescriptors = listOf( + OutputDescriptor( + "ParticipantCredential", + PARICIPANT_CREDENTIAL_SCHEMA_ID, + "ParticipantCredential" + ) + ), + presentationDefinition = PresentationDefinition( + "1", + listOf() + ) // Request empty presentation to be sent along with issuance request + ) + ).map { net.minidev.json.parser.JSONParser().parse(Klaxon().toJsonString(it)) } + ) + }.toJSONObject() + ) + } + + fun fulfillPAR(ctx: Context) { + try { + val parURI = ctx.queryParam("request_uri") ?: throw BadRequestResponse("no request_uri specified") + val sessionID = parURI.substringAfterLast("urn:ietf:params:oauth:request_uri:") + val session = IssuerManager.getIssuanceSession(sessionID) + ?: throw BadRequestResponse("No session found for given sessionId, or session expired") + val authRequest = session.authRequest ?: throw BadRequestResponse("No authorization request found for this session") + // TODO: verify VP from auth request claims + val vcclaims = OIDCUtils.getVCClaims(authRequest) + val credClaim = + vcclaims.credentials?.filter { cred -> cred.type == PARICIPANT_CREDENTIAL_SCHEMA_ID }?.firstOrNull() + ?: throw BadRequestResponse("No participant credential claim found in authorization request") + val vp_token = + credClaim.vp_token + ?: authRequest.customParameters["vp_token"]?.flatMap { + OIDCUtils.fromVpToken(it) + } ?: listOf() + val vp = vp_token.firstOrNull() ?: throw BadRequestResponse("No VP token found on authorization request") + + val verificationResult = Auditor.getService().verify( + vp.encode(), + listOf(SignaturePolicy(), ChallengePolicy(ChallengePolicyArg(IssuerManager.getValidNonces()))) + ) + if (!verificationResult.valid) { + throw BadRequestResponse("Invalid VP token given, signature (${verificationResult.policyResults["SignaturePolicy"]}) and/or challenge (${verificationResult.policyResults["ChallengePolicy"]}) could not be verified") + } + val subject = vp.subjectId + + if (subject?.let { DidUrl.from(it).method } != DidMethod.web.name) throw BadRequestResponse("did:web is required for onboarding!") + + session.did = subject + IssuerManager.updateIssuanceSession(session, session.issuables) + + val access_token = JwtService.getService() + .sign(IssuerManager.defaultDid, JWTClaimsSet.Builder().subject(session.id).build().toString()) + + ctx.status(HttpCode.FOUND) + .header( + "Location", + "${IssuerTenant.config.onboardingUiUrl}?access_token=${access_token}&sessionId=${session.id}" + ) + } catch (exc: Exception) { + exc.printStackTrace() + ctx.status(HttpCode.FOUND).header( + "Location", + "${IssuerTenant.config.issuerUiUrl}/IssuanceError/?message=${exc.message}" + ) + } + } + + fun userToken(ctx: Context) { + val accessToken = ctx.header("Authorization")?.let { it.substringAfterLast("Bearer ") } + ?: throw UnauthorizedResponse("No valid access token set on request") + val sessionId = if (JwtService.getService().verify(accessToken)) { + JwtService.getService().parseClaims(accessToken)!!["sub"].toString() + } else { + null + } + val session = sessionId?.let { IssuerManager.getIssuanceSession(it) } + ?: throw UnauthorizedResponse("Invalid access token or session expired") + val did = session.did ?: throw ForbiddenResponse("No DID specified on current session") + + val userInfo = UserInfo(did) + ctx.json(userInfo.apply { token = JWTService.toJWT(userInfo) }) + } + + fun issue(ctx: Context) { + val userInfo = JWTService.getUserInfo(ctx) ?: throw UnauthorizedResponse() + if (userInfo.did?.let { DidUrl.from(it).method } != DidMethod.web.name) { + throw BadRequestResponse("User is not did:web-authorized") + } + val session = ctx.queryParam("sessionId")?.let { IssuerManager.getIssuanceSession(it) } + ?: throw BadRequestResponse("Session expired or not found") + val authRequest = session.authRequest ?: throw BadRequestResponse("No authorization request found for this session") + if (userInfo.did != session.did) { + throw BadRequestResponse("Session DID not matching authorized DID") + } + // Use the following if we should rely on the domain used in the did:web + // val domain = DidUrl.from(userInfo.did!!).identifier.substringBefore(":").let { URLDecoder.decode(it, StandardCharsets.UTF_8) } + val domain = ctx.bodyAsClass().domain + val selectedIssuables = Issuables( + credentials = listOf( + IssuableCredential( + type = "ParticipantCredential", + credentialData = mapOf( + "credentialSubject" to mapOf( + "domain" to domain + ) + ) + ) + ) + ) + ctx.result( + "${authRequest.redirectionURI}?code=${ + IssuerManager.updateIssuanceSession( + session, + selectedIssuables + ) + }&state=${authRequest.state.value}" + ) + } +} diff --git a/src/main/kotlin/id/walt/verifier/backend/PresentationRequestInfo.kt b/src/main/kotlin/id/walt/verifier/backend/PresentationRequestInfo.kt new file mode 100644 index 0000000..a326782 --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/PresentationRequestInfo.kt @@ -0,0 +1,6 @@ +package id.walt.verifier.backend + +data class PresentationRequestInfo( + val requestId: String, + val url: String +) diff --git a/src/main/kotlin/id/walt/verifier/backend/SIOPResponseVerificationResult.kt b/src/main/kotlin/id/walt/verifier/backend/SIOPResponseVerificationResult.kt new file mode 100644 index 0000000..f3519db --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/SIOPResponseVerificationResult.kt @@ -0,0 +1,23 @@ +package id.walt.verifier.backend + +import id.walt.auditor.VerificationResult +import id.walt.common.SingleVCObject +import id.walt.common.VCObjectList +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.credentials.w3c.VerifiablePresentation + +data class VPVerificationResult( + @SingleVCObject val vp: VerifiablePresentation, + @VCObjectList val vcs: List, + val verification_result: VerificationResult +) + +data class SIOPResponseVerificationResult( + val state: String, + val subject: String?, + val vps: List, + var auth_token: String? +) { + val isValid + get() = !subject.isNullOrEmpty() && vps.isNotEmpty() && vps.all { vp -> (vp.verification_result.valid) } +} diff --git a/src/main/kotlin/id/walt/verifier/backend/VerifierConfig.kt b/src/main/kotlin/id/walt/verifier/backend/VerifierConfig.kt new file mode 100644 index 0000000..3d4af15 --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/VerifierConfig.kt @@ -0,0 +1,39 @@ +package id.walt.verifier.backend + +import com.beust.klaxon.Klaxon +import id.walt.auditor.PolicyRequest +import id.walt.multitenancy.TenantConfig +import id.walt.multitenancy.TenantConfigFactory +import id.walt.webwallet.backend.config.ExternalHostnameUrl +import id.walt.webwallet.backend.config.externalHostnameUrlValueConverter +import java.io.File + +data class VerifierConfig( + @ExternalHostnameUrl val verifierUiUrl: String = "http://localhost:8081", + @ExternalHostnameUrl val verifierApiUrl: String = "http://localhost:8080/verifier-api", + val wallets: Map = WalletConfiguration.getDefaultWalletConfigurations(), + val additionalPolicies: List? = null, + val allowedWebhookHosts: List? = null +) : TenantConfig { + companion object : TenantConfigFactory { + val CONFIG_FILE = "${id.walt.WALTID_DATA_ROOT}/config/verifier-config.json" + + override fun fromJson(json: String): VerifierConfig { + return Klaxon().fieldConverter(ExternalHostnameUrl::class, externalHostnameUrlValueConverter) + .parse(json) ?: VerifierConfig() + } + + override fun forDefaultTenant(): VerifierConfig { + val cf = File(CONFIG_FILE) + return if (cf.exists()) { + fromJson(cf.readText()) + } else { + VerifierConfig() + } + } + } + + override fun toJson(): String { + return Klaxon().fieldConverter(ExternalHostnameUrl::class, externalHostnameUrlValueConverter).toJsonString(this) + } +} diff --git a/src/main/kotlin/id/walt/verifier/backend/VerifierController.kt b/src/main/kotlin/id/walt/verifier/backend/VerifierController.kt new file mode 100644 index 0000000..ff432b7 --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/VerifierController.kt @@ -0,0 +1,517 @@ +package id.walt.verifier.backend + +import com.nimbusds.jose.util.Base64URL +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import com.nimbusds.oauth2.sdk.ResponseMode +import com.nimbusds.oauth2.sdk.ResponseType +import com.nimbusds.oauth2.sdk.Scope +import com.nimbusds.oauth2.sdk.id.ClientID +import com.nimbusds.oauth2.sdk.id.State +import com.nimbusds.openid.connect.sdk.OIDCScopeValue +import id.walt.auditor.VerificationPolicy +import id.walt.auditor.dynamic.DynamicPolicyArg +import id.walt.common.KlaxonWithConverters +import id.walt.issuer.backend.IssuerConfig +import id.walt.model.dif.InputDescriptor +import id.walt.model.dif.InputDescriptorConstraints +import id.walt.model.dif.InputDescriptorField +import id.walt.model.dif.PresentationDefinition +import id.walt.model.oidc.SIOPv2Response +import id.walt.multitenancy.Tenant +import id.walt.multitenancy.TenantId +import id.walt.rest.auditor.AuditorRestController +import id.walt.services.oidc.OIDC4VPService +import id.walt.services.oidc.OidcSchemeFixer +import id.walt.services.oidc.OidcSchemeFixer.unescapeOpenIdScheme +import id.walt.verifier.backend.VerifierManager.Companion.convertUUIDToBytes +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserRole +import id.walt.webwallet.backend.context.WalletContextManager +import io.github.pavleprica.kotlin.cache.time.based.customTimeBasedCache +import io.javalin.apibuilder.ApiBuilder.* +import io.javalin.http.* +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented +import mu.KotlinLogging +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.* + +object VerifierController { + + private val log = KotlinLogging.logger { } + + val routes + get() = + path("{tenantId}") { + before { ctx -> + //log.info { "Setting verifier API context: ${ctx.pathParam("tenantId")}" } + WalletContextManager.setCurrentContext( + VerifierManager.getService().getVerifierContext(ctx.pathParam("tenantId")) + ) + } + after { + //log.info { "Resetting verifier API context" } + WalletContextManager.resetCurrentContext() + } + path("wallets") { + get("list", documented( + document().operation { + it.summary("List wallet configurations") + .addTagsItem("Verifier") + .operationId("listWallets") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .jsonArray("200"), + VerifierController::listWallets, + )) + } + path("present") { + get(documented( + document().operation { + it.summary("Present Verifiable ID") + .addTagsItem("Verifier") + .operationId("presentVID") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("walletId") + .queryParam("schemaUri", isRepeatable = true) + .queryParam("vcType", isRepeatable = true) + .queryParam("pdByReference") { it.description("true: include presentation definition by reference, else by value (default: false)") } + .result("302"), + VerifierController::presentCredential + )) + } + path("presentXDevice") { + get(documented( + document().operation { + it.summary("Present Verifiable ID cross-device") + .addTagsItem("Verifier") + .operationId("presentXDevice") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("schemaUri", isRepeatable = true) + .queryParam("vcType", isRepeatable = true) + .queryParam("pdByReference") { it.description("true: include presentation definition by reference, else by value (default: false)") } + .result("200"), + VerifierController::presentCredentialXDevice + )) + } + path("presentLegacy") { + get(documented( + document().operation { + it.summary("Present Verifiable ID cross-device in legacy format") + .addTagsItem("Verifier") + .operationId("presentLegacy") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("walletId") + .queryParam("schemaUri", isRepeatable = true) + .queryParam("vcType", isRepeatable = true) + .result("200"), + VerifierController::presentCredentialLegacy + )) + } + path("presentXDeviceLegacy") { + get(documented( + document().operation { + it.summary("Present Verifiable ID cross-device in legacy format") + .addTagsItem("Verifier") + .operationId("presentXDeviceLegacy") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("schemaUri", isRepeatable = true) + .queryParam("vcType", isRepeatable = true) + .result("200"), + VerifierController::presentCredentialXDeviceLegacy + )) + } + get("pd/{id}", documented(document().operation { + it.summary("Get presentation definition from cache").operationId("pdFromCache").addTagsItem("Verifier") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .pathParam("id") + .json("200"), + VerifierController::getPresentationDefinitionFromCache)) + path("verify") { + post(documented( + document().operation { + it.summary("SIOPv2 request verification callback") + .addTagsItem("Verifier") + .operationId("verifySIOPv2Request") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("state") + .formParamBody { } + .result("302"), + VerifierController::verifySIOPResponse + )) + get("isVerified", + documented( + document().operation { + it.summary("SIOPv2 request verification callback receiver") + .addTagsItem("Verifier") + .operationId("isVerified") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("state") + .result("404") + .result("200"), + VerifierController::hasRecentlyVerified) + ) + } + path("config") { + post("setConfiguration", documented(document().operation { + it.summary("Set configuration for this verifier tenant").operationId("setConfiguration") + .addTagsItem("Verifier Configuration") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .body() + .json("200"), VerifierController::setConfiguration)) + get("getConfiguration", documented(document().operation { + it.summary("Get configuration for this verifier tenant").operationId("getConfiguration") + .addTagsItem("Verifier Configuration") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .json("200"), VerifierController::getConfiguration + )) + path("policies") { + get( + "list", + documented(document().operation { + it.summary("List verification policies").operationId("listPolicies") + .addTagsItem("Verifier Configuration") + }.pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .json>("200"), AuditorRestController::listPolicies) + ) + post( + "create/{name}", + documented( + document().operation { + it.summary("Create dynamic verification policy").operationId("createDynamicPolicy") + .addTagsItem("Verifier Configuration") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .pathParam("name") + .queryParam("update") + .queryParam("downloadPolicy") + .body() + .json("200"), + AuditorRestController::createDynamicPolicy + ) + ) + delete( + "delete/{name}", + documented( + document().operation { + it.summary("Delete a dynamic verification policy").operationId("deletePolicy") + .addTagsItem("Verifier Configuration") + }.pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .pathParam("name"), + AuditorRestController::deleteDynamicPolicy + ) + ) + } + } + path("auth") { + get(documented( + document().operation { + it.summary("Complete authentication by siopv2 verification") + .addTagsItem("Verifier") + .operationId("completeAuthentication") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .queryParam("access_token") + .json>("200"), + VerifierController::completeAuthentication + )) + } + path("protected") { + get(documented( + document().operation { + it.summary("Fetch protected data (example)") + .addTagsItem("Verifier") + .operationId("get protected data") + } + .pathParam("tenantId") { it.example(TenantId.DEFAULT_TENANT) } + .result("200"), + VerifierController::getProtectedData + ), UserRole.AUTHORIZED) + } + } + + private fun getPresentationDefinitionFromCache(context: Context) { + val id = context.pathParam("id") + val pd = VerifierTenant.state.presentationDefinitionCache.getIfPresent(id) + ?: throw BadRequestResponse("Presentation definition id invalid or expired") + context.contentType(ContentType.APPLICATION_JSON).result(KlaxonWithConverters().toJsonString(pd)) + } + + private fun getConfiguration(context: Context) { + try { + context.json(VerifierTenant.config) + } catch (nfe: Tenant.TenantNotFoundException) { + throw NotFoundResponse() + } + } + + private fun setConfiguration(context: Context) { + val config = context.bodyAsClass() + VerifierTenant.setConfig(config) + } + + fun listWallets(ctx: Context) { + ctx.json(VerifierTenant.config.wallets.values) + } + + private fun getPresentationCustomQueryParams(queryParamMap: Map>): String { + val standardQueryParams = listOf("walletId", "schemaUri", "vcType", "verificationCallbackUrl", "pdByReference") + + val customQueryParams = queryParamMap + .filter { it.key !in standardQueryParams } + .flatMap { entry -> + entry.value.map { value -> "${entry.key}=${URLEncoder.encode(value, StandardCharsets.UTF_8)}" } + }.joinToString("&") + + return customQueryParams + } + + private fun Context.getSchemaOrVcType(): Triple, List, String?> { + val schemaUris = queryParams("schemaUri") + val vcTypes = queryParams("vcType") + if (schemaUris.isEmpty() && vcTypes.isEmpty()) { + throw BadRequestResponse("No schema URI(s) or VC type(s) given") + } + val verificationCallbackUrl: String? = queryParam("verificationCallbackUrl") + + return Triple(schemaUris, vcTypes, verificationCallbackUrl) + } + + val verifierManager = VerifierManager.getService() + + fun presentCredential(ctx: Context) { + val wallet = ctx.queryParam("walletId")?.let { VerifierTenant.config.wallets[it] } + ?: throw BadRequestResponse("Unknown or missing walletId") + + val (schemaUris, vcTypes, verificationCallbackUrl) = ctx.getSchemaOrVcType() + log.debug { "Found requested callback: $verificationCallbackUrl" } + + val customQueryParams = getPresentationCustomQueryParams(ctx.queryParamMap()) + + val req = verifierManager.newRequestBySchemaOrVc( + walletUrl = "${wallet.url}/${wallet.presentPath}", + schemaUris = schemaUris.toSet(), + vcTypes = vcTypes.toSet(), + redirectCustomUrlQuery = customQueryParams, + responseMode = ResponseMode.FORM_POST, + verificationCallbackUrl = verificationCallbackUrl, + presentationDefinitionByReference = ctx.queryParam("pdByReference")?.toBoolean() ?: false + ) + + ctx.status(HttpCode.FOUND).header("Location", req.toURI().unescapeOpenIdScheme().toString()) + } + + fun presentCredentialLegacy(ctx: Context) { + val wallet = ctx.queryParam("walletId")?.let { VerifierTenant.config.wallets[it] } + ?: throw BadRequestResponse("Unknown or missing walletId") + + val (_, vcTypes, verificationCallbackUrl) = ctx.getSchemaOrVcType() + log.debug { "Found requested callback: $verificationCallbackUrl" } + + val walletUrl = URI.create("${wallet.url}/${wallet.presentPath}") + val redirectUri = URI.create("${VerifierTenant.config.verifierApiUrl}/verify") + + val nonce = Base64URL.encode(convertUUIDToBytes(UUID.randomUUID())).toString() + val customParams = mutableMapOf("nonce" to listOf(nonce)) + + val presentation_definition = PresentationDefinition( + id = "1", + input_descriptors = vcTypes.mapIndexed { index, vcType -> + InputDescriptor( + id = "${index + 1}", + constraints = InputDescriptorConstraints( + fields = listOf( + InputDescriptorField( + path = listOf("$.type"), + filter = mapOf( + "const" to vcType + ) + ) + ) + ) + ) + }.toList() + ) + + //val presentationDefinitionKey = "presentation_definition" + //val presentationDefinitionValue = KlaxonWithConverters().toJsonString(presentation_definition) + //customParams[presentationDefinitionKey] = listOf(presentationDefinitionValue) + + customParams["claims"] = listOf( + KlaxonWithConverters().toJsonString( + mapOf( + //"id_token" to "", + "vp_token" to mapOf( + "presentation_definition" to presentation_definition + ) + ) + ) + ) + val req2 = AuthorizationRequest( + walletUrl, + ResponseType("id_token"), + ResponseMode("form_post"), + ClientID(redirectUri.toString()), + redirectUri, Scope(OIDCScopeValue.OPENID), + State(nonce), + null, null, null, false, null, null, null, + customParams + ) + VerifierTenant.state.reqCache.put(nonce, OIDC4VPService.parseOIDC4VPRequestUri(req2.toURI())) + + ctx.status(HttpCode.FOUND).header("Location", req2.toURI().toString()) + } + + fun presentCredentialXDevice(ctx: Context) { + val (schemaUris, vcTypes, verificationCallbackUrl) = ctx.getSchemaOrVcType() + + val customQueryParams = getPresentationCustomQueryParams(ctx.queryParamMap()) + + val req = verifierManager.newRequestBySchemaOrVc( + walletUrl = "openid://", + schemaUris = schemaUris.toSet(), + vcTypes = vcTypes.toSet(), + redirectCustomUrlQuery = customQueryParams, + responseMode = ResponseMode("post"), + verificationCallbackUrl = verificationCallbackUrl, + presentationDefinitionByReference = ctx.queryParam("pdByReference")?.toBoolean() ?: false + ) + + ctx.json(PresentationRequestInfo(req.state.value, req.toURI().unescapeOpenIdScheme().toString())) + } + + + fun presentCredentialXDeviceLegacy(ctx: Context) { + + val (_, vcTypes, verificationCallbackUrl) = ctx.getSchemaOrVcType() + + log.debug { "Found requested callback: $verificationCallbackUrl" } + + val redirectUri = URI.create("${VerifierTenant.config.verifierApiUrl}/verify") + + val nonce = Base64URL.encode(convertUUIDToBytes(UUID.randomUUID())).toString() + val customParams = mutableMapOf("nonce" to listOf(nonce)) + + val presentation_definition = PresentationDefinition( + id = "1", + input_descriptors = vcTypes.mapIndexed { index, vcType -> + InputDescriptor( + id = "${index + 1}", + constraints = InputDescriptorConstraints( + fields = listOf( + InputDescriptorField( + path = listOf("$.type"), + filter = mapOf( + "const" to vcType + ) + ) + ) + ) + ) + }.toList() + ) + + customParams["claims"] = listOf( + KlaxonWithConverters().toJsonString( + mapOf( + //"id_token" to "", + "vp_token" to mapOf( + "presentation_definition" to presentation_definition + ) + ) + ) + ) + + val req2 = AuthorizationRequest( + OidcSchemeFixer.openIdSchemeFixUri, + ResponseType("id_token"), + ResponseMode("post"), + ClientID(redirectUri.toString()), + redirectUri, Scope(OIDCScopeValue.OPENID), + State(nonce), + null, null, null, false, null, null, null, + customParams + ) + + VerifierTenant.state.reqCache.put(nonce, OIDC4VPService.parseOIDC4VPRequestUri(req2.toURI())) + + ctx.json(PresentationRequestInfo(req2.state.value, req2.toURI().unescapeOpenIdScheme().toString())) + } + + val recentlyVerifiedResponses = customTimeBasedCache(java.time.Duration.ofSeconds(300)) + + fun hasRecentlyVerified(ctx: Context) { + val accessToken = ctx.queryParam("state").toString() + val opt = recentlyVerifiedResponses[accessToken] + + when { + opt.isPresent -> ctx.status(200).result(opt.get()) + opt.isEmpty -> ctx.status(404) + } + } + + fun verifySIOPResponse(ctx: Context) { + log.debug { "Verifying SIOP response..." } + val verifierUiUrl = ctx.queryParam("verifierUiUrl") ?: VerifierTenant.config.verifierUiUrl + val siopResponse = + SIOPv2Response.fromFormParams(ctx.formParamMap().map { kv -> Pair(kv.key, kv.value.first()) }.toMap()) + + val result = verifierManager.verifyResponse(siopResponse) + val siopVerificationResult = result.first + val callbackRequestedRedirectUrl = result.second + + val accessToken = siopResponse.state!! + + log.debug { "$accessToken: SIOP requests response: $siopVerificationResult" } + log.debug { "$accessToken: The UI URL $verifierUiUrl has run through the verification process." } + log.debug { "$accessToken: Callback?: $callbackRequestedRedirectUrl" } + + val url = if (callbackRequestedRedirectUrl != null) { + callbackRequestedRedirectUrl + } else { + val url = verifierManager.getVerificationRedirectionUri(siopVerificationResult, verifierUiUrl).toString() + + log.debug { "Setting recentlyVerifiedResponses for \"$accessToken\" to redirect to \"$url\"" } + recentlyVerifiedResponses[accessToken] = url + + url + } + log.debug { "Now $accessToken will be redirected to $url!" } + + ctx.status(HttpCode.FOUND).header("Location", url) + } + + fun completeAuthentication(ctx: Context) { + val access_token = ctx.queryParam("access_token") + if (access_token == null) { + ctx.status(HttpCode.FORBIDDEN).result("Unknown access token.") + return + } + val result = VerifierManager.getService().getVerificationResult(access_token) + if (result == null) { + ctx.status(HttpCode.FORBIDDEN).result("Invalid SIOP Response Verification Result.") + return + } + ctx.contentType(ContentType.JSON).result(KlaxonWithConverters().toJsonString(result)) + } + + fun getProtectedData(ctx: Context) { + val userInfo = JWTService.getUserInfo(ctx) + if (userInfo != null) { + ctx.result("Account balance: EUR 0.00") + } else { + ctx.status(HttpCode.FORBIDDEN) + } + } +} diff --git a/src/main/kotlin/id/walt/verifier/backend/VerifierManager.kt b/src/main/kotlin/id/walt/verifier/backend/VerifierManager.kt new file mode 100644 index 0000000..a9d9c8c --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/VerifierManager.kt @@ -0,0 +1,315 @@ +package id.walt.verifier.backend + +import com.beust.klaxon.JsonObject +import com.beust.klaxon.Klaxon +import com.nimbusds.jose.util.Base64URL +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import com.nimbusds.oauth2.sdk.ResponseMode +import com.nimbusds.oauth2.sdk.id.State +import com.nimbusds.openid.connect.sdk.Nonce +import id.walt.auditor.* +import id.walt.model.dif.* +import id.walt.model.oidc.SIOPv2Response +import id.walt.multitenancy.TenantContext +import id.walt.multitenancy.TenantContextManager +import id.walt.multitenancy.TenantId +import id.walt.multitenancy.TenantType +import id.walt.servicematrix.BaseService +import id.walt.servicematrix.ServiceRegistry +import id.walt.services.oidc.OIDC4VPService +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserInfo +import io.github.pavleprica.kotlin.cache.time.based.longTimeBasedCache +import io.javalin.http.BadRequestResponse +import io.javalin.http.UnauthorizedResponse +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging +import java.net.URI +import java.nio.ByteBuffer +import java.util.* + +abstract class VerifierManager : BaseService() { + open fun getVerifierContext(tenantId: String): TenantContext { + return TenantContextManager.getTenantContext(TenantId(TenantType.VERIFIER, tenantId)) { VerifierState() } + } + + private val log = KotlinLogging.logger { } + + open fun newRequest( + walletUrl: String, + presentationDefinition: PresentationDefinition, + state: String? = null, + redirectCustomUrlQuery: String = "", + responseMode: ResponseMode = ResponseMode.FORM_POST, + presentationDefinitionByReference: Boolean = false, + nonce: String? = null + ): AuthorizationRequest { + val nonce = nonce ?: Base64URL.encode(convertUUIDToBytes(UUID.randomUUID())).toString() + val requestId = state ?: nonce + val redirectQuery = if (redirectCustomUrlQuery.isEmpty()) "" else "?$redirectCustomUrlQuery" + val req = OIDC4VPService.createOIDC4VPRequest( + wallet_url = walletUrl, + redirect_uri = URI.create("${VerifierTenant.config.verifierApiUrl}/verify$redirectQuery"), + response_mode = responseMode, + nonce = Nonce(nonce), + presentation_definition = if (presentationDefinitionByReference) null else presentationDefinition, + presentation_definition_uri = if (presentationDefinitionByReference) + URI.create("${VerifierTenant.config.verifierApiUrl}/pd/$requestId").also { + VerifierTenant.state.presentationDefinitionCache.put(requestId, presentationDefinition) + } else null, + state = State(requestId) + ) + VerifierTenant.state.reqCache.put(requestId, req) + + return req + } + + private fun isWebhookAllowed(requestedWebhookUrl: String): Boolean { + val allowedWebhooks = VerifierTenant.config.allowedWebhookHosts + + if (allowedWebhooks == null) { + log.debug { "No allowedWebhookHosts attribute in verifier config, but webhook was requested." } + return false + } + + return allowedWebhooks.any { allowedUrl -> requestedWebhookUrl.startsWith(allowedUrl) } + } + + fun newRequestBySchemaOrVc( + walletUrl: String, + schemaUris: Set, + vcTypes: Set, + state: String? = null, + redirectCustomUrlQuery: String = "", + responseMode: ResponseMode = ResponseMode.FORM_POST, + verificationCallbackUrl: String? = null, + presentationDefinitionByReference: Boolean = false, + nonce: String? = null + ): AuthorizationRequest { + val request = when { + schemaUris.isNotEmpty() -> newRequestBySchemaUris( + walletUrl, + schemaUris, + state, + redirectCustomUrlQuery, + responseMode, + presentationDefinitionByReference, + nonce + ) + + else -> newRequestByVcTypes( + walletUrl, + vcTypes, + state, + redirectCustomUrlQuery, + responseMode, + presentationDefinitionByReference, + nonce + ) + } + + if (verificationCallbackUrl != null) { + log.debug { "Callback is set: $verificationCallbackUrl" } + if (isWebhookAllowed(verificationCallbackUrl)) { + log.debug { "Registered webhook for ${request.state.value}: $verificationCallbackUrl" } + verificationCallbacks[request.state.value] = verificationCallbackUrl + } else { + throw UnauthorizedResponse("This web hook is not allowed.") + } + } + + return request + } + + open fun newRequestBySchemaUris( + walletUrl: String, + schemaUris: Set, + state: String? = null, + redirectCustomUrlQuery: String = "", + responseMode: ResponseMode = ResponseMode.FORM_POST, + presentationDefinitionByReference: Boolean = false, + nonce: String? = null + ): AuthorizationRequest { + return newRequest( + walletUrl, PresentationDefinition( + id = "1", + input_descriptors = schemaUris.mapIndexed { index, schemaUri -> + InputDescriptor( + id = "${index + 1}", + schema = VCSchema(uri = schemaUri) + ) + }.toList() + ), state, redirectCustomUrlQuery, responseMode, presentationDefinitionByReference, nonce + ) + } + + open fun newRequestByVcTypes( + walletUrl: String, + vcTypes: Set, + state: String? = null, + redirectCustomUrlQuery: String = "", + responseMode: ResponseMode = ResponseMode.FORM_POST, + presentationDefinitionByReference: Boolean = false, + nonce: String? = null + ): AuthorizationRequest { + return newRequest( + walletUrl, PresentationDefinition( + id = "1", + input_descriptors = vcTypes.mapIndexed { index, vcType -> + InputDescriptor( + id = "${index + 1}", + constraints = InputDescriptorConstraints( + fields = listOf( + InputDescriptorField( + path = listOf("$.type"), + filter = mapOf( + "const" to vcType + ) + ) + ) + ) + ) + }.toList() + ), state, redirectCustomUrlQuery, responseMode, presentationDefinitionByReference, nonce + ) + } + + open fun getVerificationPoliciesFor(req: AuthorizationRequest): List { + log.info { "Getting verification policies for request: ${req.toURI()}" } + return listOf( + SignaturePolicy(), + ChallengePolicy(req.getCustomParameter("nonce")!!.first(), applyToVC = false, applyToVP = true), + PresentationDefinitionPolicy( + VerifierTenant.state.presentationDefinitionCache.getIfPresent(req.state.value) + ?: OIDC4VPService.getPresentationDefinition(req) + ), + *(VerifierTenant.config.additionalPolicies?.map { p -> + PolicyRegistry.getPolicyWithJsonArg(p.policy, p.argument?.let { JsonObject(it) }) + }?.toList() ?: listOf()).toTypedArray() + ) + } + + private val verificationCallbackHttpClient = HttpClient(CIO) { + //install(ContentNegotiation) { + //jackson() + //} + install(Logging) { + level = LogLevel.ALL + } + followRedirects = false + } + + val verificationCallbacks = longTimeBasedCache() + + /* + - find cached request + - parse and verify id_token + - parse and verify vp_token + - - compare nonce (verification policy) + - - compare token_claim => token_ref => vp (verification policy) + */ + open fun verifyResponse(siopResponse: SIOPv2Response): Pair { + val state = siopResponse.state ?: throw BadRequestResponse("No state set on SIOP response") + val req = VerifierTenant.state.reqCache.getIfPresent(state) ?: throw BadRequestResponse("State invalid or expired") + VerifierTenant.state.reqCache.invalidate(state) + val verifiablePresentations = siopResponse.vp_token + + val result = SIOPResponseVerificationResult( + state = state, + subject = verifiablePresentations.firstOrNull { !it.holder.isNullOrEmpty() }?.holder, + vps = verifiablePresentations.map { vp -> + VPVerificationResult( + vp = vp, + vcs = vp.verifiableCredential ?: listOf(), + verification_result = Auditor.getService().verify( + vp, getVerificationPoliciesFor(req) + ) + ) + }, auth_token = null + ) + + var overallValid = result.isValid + + // Verification callback + val callbackRequestedRedirectUrl = if (verificationCallbacks[state].isPresent) { + val callbackUrl = verificationCallbacks[state].get() + log.debug { "Sending callback post to: \"$callbackUrl\" for state \"$state\"" } + val response = runBlocking { + verificationCallbackHttpClient.post(callbackUrl) { + contentType(ContentType.Application.Json) + setBody(Klaxon().toJsonString(result)) + } + } + log.debug { "Callback response: $response" } + + when (response.status) { + in setOf(HttpStatusCode.OK, HttpStatusCode.Accepted, HttpStatusCode.NoContent) -> { + verificationCallbacks.remove(state) + null + } + + in setOf( + HttpStatusCode.MovedPermanently, HttpStatusCode.PermanentRedirect, + HttpStatusCode.Found, HttpStatusCode.SeeOther, HttpStatusCode.TemporaryRedirect + ) -> response.headers[HttpHeaders.Location] + + in setOf( + HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden + ) -> { + overallValid = false + null + } + + else -> null + } + } else null + + if (result.isValid && overallValid) { + result.auth_token = JWTService.toJWT(UserInfo(result.subject!!)) + } + + VerifierTenant.state.respCache.put(result.state, result) + + return Pair(result, callbackRequestedRedirectUrl) + } + + open fun getVerificationRedirectionUri( + verificationResult: SIOPResponseVerificationResult, + uiUrl: String? = VerifierTenant.config.verifierUiUrl + ): URI { + return URI.create("$uiUrl/success/?access_token=${verificationResult.state}") + /*if(verificationResult.isValid) + return URI.create("$uiUrl/success/?access_token=${verificationResult.state}") + else + return URI.create("$uiUrl/error/?access_token=${verificationResult.state ?: ""}") + + */ + } + + fun getVerificationResult(id: String): SIOPResponseVerificationResult? { + return VerifierTenant.state.respCache.getIfPresent(id).also { + VerifierTenant.state.respCache.invalidate(id) + } + } + + override val implementation: BaseService + get() = serviceImplementation() + + companion object { + fun getService(): VerifierManager = ServiceRegistry.getService() + + fun convertUUIDToBytes(uuid: UUID): ByteArray? { + val bb: ByteBuffer = ByteBuffer.wrap(ByteArray(16)) + bb.putLong(uuid.mostSignificantBits) + bb.putLong(uuid.leastSignificantBits) + return bb.array() + } + } +} + +class DefaultVerifierManager : VerifierManager() diff --git a/src/main/kotlin/id/walt/verifier/backend/VerifierState.kt b/src/main/kotlin/id/walt/verifier/backend/VerifierState.kt new file mode 100644 index 0000000..9cada53 --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/VerifierState.kt @@ -0,0 +1,17 @@ +package id.walt.verifier.backend + +import com.google.common.cache.CacheBuilder +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import id.walt.model.dif.PresentationDefinition +import id.walt.multitenancy.TenantState +import java.util.concurrent.* + +class VerifierState : TenantState { + val reqCache = CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build() + val respCache = + CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build() + val presentationDefinitionCache = + CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build() + + override var config: VerifierConfig? = null +} diff --git a/src/main/kotlin/id/walt/verifier/backend/VerifierTenant.kt b/src/main/kotlin/id/walt/verifier/backend/VerifierTenant.kt new file mode 100644 index 0000000..5ddb1a8 --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/VerifierTenant.kt @@ -0,0 +1,5 @@ +package id.walt.verifier.backend + +import id.walt.multitenancy.Tenant + +object VerifierTenant : Tenant(VerifierConfig) diff --git a/src/main/kotlin/id/walt/verifier/backend/WalletConfiguration.kt b/src/main/kotlin/id/walt/verifier/backend/WalletConfiguration.kt new file mode 100644 index 0000000..18dbd63 --- /dev/null +++ b/src/main/kotlin/id/walt/verifier/backend/WalletConfiguration.kt @@ -0,0 +1,29 @@ +package id.walt.verifier.backend + +import id.walt.webwallet.backend.config.ExternalHostnameUrl + +data class WalletConfiguration( + val id: String, + @ExternalHostnameUrl val url: String, + val presentPath: String, + val receivePath: String, + val description: String +) { + + companion object { + fun getDefaultWalletConfigurations(): Map { + return mapOf( + Pair( + "walt.id", + WalletConfiguration( + "walt.id", + "http://localhost:3000", + "api/siop/initiatePresentation", + "api/siop/initiateIssuance", + "walt.id web wallet" + ) + ) + ) + } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/auth/AuthController.kt b/src/main/kotlin/id/walt/webwallet/backend/auth/AuthController.kt new file mode 100644 index 0000000..d8d259b --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/auth/AuthController.kt @@ -0,0 +1,54 @@ +package id.walt.webwallet.backend.auth + +import id.walt.model.DidMethod +import id.walt.services.context.ContextManager +import id.walt.services.did.DidService +import id.walt.webwallet.backend.context.WalletContextManager +import io.javalin.apibuilder.ApiBuilder.* +import io.javalin.http.Context +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented + +object AuthController { + val routes + get() = path("auth") { + path("login") { + post(documented(document().operation { + it.summary("Login") + .operationId("login") + .addTagsItem("Authentication") + } + .body { it.description("Login info") } + .json("200"), + AuthController::login), UserRole.UNAUTHORIZED) + } + path("userInfo") { + get( + documented(document().operation { + it.summary("Get current user info") + .operationId("userInfo") + .addTagsItem("Authentication") + } + .json("200"), + AuthController::userInfo), UserRole.AUTHORIZED) + } + } + + fun login(ctx: Context) { + val userInfo = ctx.bodyAsClass(UserInfo::class.java) + // TODO: verify login credentials!! + ContextManager.runWith(WalletContextManager.getUserContext(userInfo)) { + if (DidService.listDids().isEmpty()) { + DidService.create(DidMethod.key) + } + } + ctx.json(UserInfo(userInfo.id).apply { + token = JWTService.toJWT(userInfo) + }) + } + + fun userInfo(ctx: Context) { + println("666Context: ${ctx} + \n ${JWTService.getUserInfo(ctx)} + \n ${ctx.json(JWTService.getUserInfo(ctx)!!)}") + ctx.json(JWTService.getUserInfo(ctx)!!) + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/auth/JWTService.kt b/src/main/kotlin/id/walt/webwallet/backend/auth/JWTService.kt new file mode 100644 index 0000000..590fe16 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/auth/JWTService.kt @@ -0,0 +1,57 @@ +package id.walt.webwallet.backend.auth + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.interfaces.DecodedJWT +import io.javalin.core.security.AccessManager +import io.javalin.core.security.RouteRole +import io.javalin.http.Context +import io.javalin.http.Handler +import javalinjwt.JWTProvider +import javalinjwt.JavalinJWT +import java.util.* + + +object JWTService : AccessManager { + + val secret = System.getenv("WALTID_WALLET_AUTH_SECRET") ?: UUID.randomUUID().toString() + val algorithm: Algorithm = Algorithm.HMAC256(secret) + + val provider = JWTProvider( + algorithm, + { user: UserInfo, alg: Algorithm? -> + JWT.create().withSubject(user.id).sign(alg) + }, + JWT.require(algorithm).build() + ) + + val jwtHandler: Handler + get() = JavalinJWT.createHeaderDecodeHandler(provider) + + fun toJWT(user: UserInfo): String { + return provider.generateToken(user) + } + + fun fromJwt(jwt: DecodedJWT): UserInfo { + return UserInfo(jwt.subject).apply { + token = jwt.token + } + } + + fun getUserInfo(ctx: Context): UserInfo? = when (JavalinJWT.containsJWT(ctx)) { + true -> fromJwt(JavalinJWT.getDecodedFromContext(ctx)) + else -> null + } + + override fun manage(handler: Handler, ctx: Context, routeRoles: MutableSet) { + // if context contains decoded JWT, it was already validated by jwtHandler + if ( + (JavalinJWT.containsJWT(ctx) && routeRoles.contains(UserRole.AUTHORIZED)) + || !routeRoles.contains(UserRole.AUTHORIZED) + ) { + handler.handle(ctx) + } else { + ctx.status(401).result("Unauthorized") + } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/auth/UserInfo.kt b/src/main/kotlin/id/walt/webwallet/backend/auth/UserInfo.kt new file mode 100644 index 0000000..b26ae97 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/auth/UserInfo.kt @@ -0,0 +1,24 @@ +package id.walt.webwallet.backend.auth + +import kotlinx.serialization.Serializable + +@Serializable +class UserInfo( + val id: String +) { + var email: String? = null + var password: String? = null + var token: String? = null + var ethAccount: String? = null + var did: String? = null + var tezosAccount: String? = null + + init { + when { + id.contains("@") -> email = id + id.lowercase().contains("0x") -> ethAccount = id + id.lowercase().startsWith("did:") -> did = id + id.lowercase().startsWith("tz") -> tezosAccount = id + } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/auth/UserRole.kt b/src/main/kotlin/id/walt/webwallet/backend/auth/UserRole.kt new file mode 100644 index 0000000..02c8f4c --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/auth/UserRole.kt @@ -0,0 +1,8 @@ +package id.walt.webwallet.backend.auth + +import io.javalin.core.security.RouteRole + +enum class UserRole : RouteRole { + UNAUTHORIZED, + AUTHORIZED +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/cli/ConfigCmd.kt b/src/main/kotlin/id/walt/webwallet/backend/cli/ConfigCmd.kt new file mode 100644 index 0000000..22a194f --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/cli/ConfigCmd.kt @@ -0,0 +1,32 @@ +package id.walt.webwallet.backend.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions +import com.github.ajalt.clikt.parameters.groups.required +import com.github.ajalt.clikt.parameters.groups.single +import com.github.ajalt.clikt.parameters.options.convert +import com.github.ajalt.clikt.parameters.options.option +import id.walt.issuer.backend.IssuerManager +import id.walt.services.context.Context +import id.walt.verifier.backend.VerifierManager +import id.walt.webwallet.backend.context.UserContextLoader +import id.walt.webwallet.backend.context.WalletContextManager +import mu.KotlinLogging + +class ConfigCmd : CliktCommand(name = "config", help = "Configure or setup dids, keys, etc.") { + + private val log = KotlinLogging.logger {} + + val context: Context by mutuallyExclusiveOptions( + option("-i", "--as-issuer", help = "Execute in context of issuer backend") + .convert { tenantId -> IssuerManager.getIssuerContext(tenantId) }, + option("-v", "--as-verifier", help = "Execute in context of verifier backend") + .convert { tenantId -> VerifierManager.getService().getVerifierContext(tenantId) }, + option("-u", "--as-user", help = "Execute in user context").convert { userId -> UserContextLoader.load(userId) } + ).single().required() + + override fun run() { + log.info("Running in context of: $context") + WalletContextManager.setCurrentContext(context) + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/cli/RunCmd.kt b/src/main/kotlin/id/walt/webwallet/backend/cli/RunCmd.kt new file mode 100644 index 0000000..c2a69d5 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/cli/RunCmd.kt @@ -0,0 +1,46 @@ +package id.walt.webwallet.backend.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.groups.default +import com.github.ajalt.clikt.parameters.groups.mutuallyExclusiveOptions +import com.github.ajalt.clikt.parameters.groups.single +import com.github.ajalt.clikt.parameters.options.convert +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.int +import id.walt.WALTID_WALLET_BACKEND_BIND_ADDRESS +import id.walt.WALTID_WALLET_BACKEND_PORT +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.context.WalletContextManager +import id.walt.webwallet.backend.rest.RestAPI +import mu.KotlinLogging + +class RunCmd : CliktCommand(name = "run", help = "Run walletkit service") { + + private val log = KotlinLogging.logger {} + + val bindAddress: String by mutuallyExclusiveOptions( + option( + "-b", + "--bind-address", + help = "Bind to address/interface, defaults to env. variable WALTID_WALLET_BACKEND_BIND_ADDRESS: $WALTID_WALLET_BACKEND_BIND_ADDRESS" + ), + option("--bind-all", help = "Bind to all interfaces").flag().convert { if (it) "0.0.0.0"; else null } + ).single().default(WALTID_WALLET_BACKEND_BIND_ADDRESS) + + val bindPort: Int by option( + "-p", + "--port", + help = "Bind to port, defaults to env. variable WALTID_WALLET_BACKEND_PORT: $WALTID_WALLET_BACKEND_PORT" + ).int().default(WALTID_WALLET_BACKEND_PORT) + + override fun run() { + RestAPI.start(bindAddress, bindPort, JWTService).apply { + before(JWTService.jwtHandler) + before(WalletContextManager.preRequestHandler) + after(WalletContextManager.postRequestHandler) + } + } + +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/cli/WalletCmd.kt b/src/main/kotlin/id/walt/webwallet/backend/cli/WalletCmd.kt new file mode 100644 index 0000000..34cc53d --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/cli/WalletCmd.kt @@ -0,0 +1,11 @@ +package id.walt.webwallet.backend.cli + +import com.github.ajalt.clikt.core.CliktCommand + +class WalletCmd : CliktCommand(name = "wallet", help = "SSI walletkit by walt.id") { + + override fun run() { + + } + +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/config/ExternalHostnameUrl.kt b/src/main/kotlin/id/walt/webwallet/backend/config/ExternalHostnameUrl.kt new file mode 100644 index 0000000..649ab64 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/config/ExternalHostnameUrl.kt @@ -0,0 +1,38 @@ +package id.walt.webwallet.backend.config + +import com.beust.klaxon.Converter +import com.beust.klaxon.JsonValue +import com.beust.klaxon.Klaxon +import id.walt.model.oidc.OIDCProvider +import java.io.File +import java.nio.charset.StandardCharsets + +@Target(AnnotationTarget.FIELD) +annotation class ExternalHostnameUrl + +fun getExternalHostname(): String? { + return System.getenv("EXTERNAL_HOSTNAME") + ?: System.getenv("HOSTNAMEE") // linux + ?: File("/etc/hostname").let { file -> // linux alternative + if (file.exists()) { + file.readText(StandardCharsets.UTF_8).trim() + } else { + null + } + } + ?: System.getenv("COMPUTERNAME") // windows +} + +fun replaceExternalHostname(url: String): String = getExternalHostname()?.let { url.replace("\$EXTERNAL_HOSTNAME", it) } ?: url + +val externalHostnameUrlValueConverter = object : Converter { + override fun canConvert(cls: Class<*>) = cls == String::class.java + + override fun fromJson(jv: JsonValue) = + jv.string?.let { replaceExternalHostname(it) } + + override fun toJson(value: Any) = Klaxon().toJsonString(value) +} + +fun OIDCProvider.withExternalHostnameUrl(): OIDCProvider = + OIDCProvider(this.id, replaceExternalHostname(this.url), this.description, this.client_id, this.client_secret) diff --git a/src/main/kotlin/id/walt/webwallet/backend/config/SecretConfig.kt b/src/main/kotlin/id/walt/webwallet/backend/config/SecretConfig.kt new file mode 100644 index 0000000..7a23561 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/config/SecretConfig.kt @@ -0,0 +1,21 @@ +package id.walt.webwallet.backend.config + +import id.walt.model.oidc.OIDCProvider + +data class SecretConfig( + val client_id: String, + val client_secret: String +) + +data class SecretConfigMap( + val secrets: Map +) + +fun OIDCProvider.withSecret(secretConfig: SecretConfig?): OIDCProvider = + OIDCProvider( + this.id, + this.url, + this.description, + secretConfig?.client_id ?: this.client_id, + secretConfig?.client_secret ?: this.client_secret + ) diff --git a/src/main/kotlin/id/walt/webwallet/backend/config/WalletConfig.kt b/src/main/kotlin/id/walt/webwallet/backend/config/WalletConfig.kt new file mode 100644 index 0000000..5617783 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/config/WalletConfig.kt @@ -0,0 +1,46 @@ +package id.walt.webwallet.backend.config + +import com.beust.klaxon.Klaxon +import id.walt.common.prettyPrint +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.credentials.w3c.templates.VcTemplateManager +import id.walt.customTemplates.EHIC +import id.walt.model.oidc.OIDCProvider +import java.io.File +import java.nio.file.Paths + +data class WalletConfig( + @ExternalHostnameUrl val walletUiUrl: String = "http://localhost:8080", + @ExternalHostnameUrl val walletApiUrl: String = "http://localhost:8080/api", + var issuers: Map = mapOf() +) { + companion object { + val CONFIG_FILE = "${id.walt.WALTID_DATA_ROOT}/config/wallet-config.json" + val ISSUERS_SECRETS = "${id.walt.WALTID_DATA_ROOT}/secrets/issuers.json" + lateinit var config: WalletConfig + + init { + val path = Paths.get("").toAbsolutePath().toString() + + val cf = File(CONFIG_FILE) + if (cf.exists()) { + config = Klaxon().fieldConverter(ExternalHostnameUrl::class, externalHostnameUrlValueConverter) + .parse(cf) ?: WalletConfig() + } else { + config = WalletConfig() + } + + val issuerSecretsFile = File(ISSUERS_SECRETS) + val issuerSecrets = when (issuerSecretsFile.exists()) { + true -> Klaxon().parse(issuerSecretsFile) ?: SecretConfigMap(mapOf()) + else -> SecretConfigMap(mapOf()) + } + + config.issuers = config.issuers.values.associate { issuer -> + issuer.id to issuer.withSecret(issuerSecrets.secrets[issuer.id]).withExternalHostnameUrl() + } + + } + } +} + diff --git a/src/main/kotlin/id/walt/webwallet/backend/context/UserContext.kt b/src/main/kotlin/id/walt/webwallet/backend/context/UserContext.kt new file mode 100644 index 0000000..7ccea6b --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/context/UserContext.kt @@ -0,0 +1,26 @@ +package id.walt.webwallet.backend.context + +import id.walt.services.context.Context +import id.walt.services.hkvstore.FileSystemHKVStore +import id.walt.services.hkvstore.HKVStoreService +import id.walt.services.keystore.KeyStoreService +import id.walt.services.vcstore.VcStoreService + +class UserContext( + val contextId: String, + override val keyStore: KeyStoreService, + override val vcStore: VcStoreService, + override val hkvStore: HKVStoreService +) : Context { + override fun toString(): String { + return contextId + } + + fun resetAllData() { + if (hkvStore is FileSystemHKVStore) { + hkvStore.configuration.dataDirectory.toFile().deleteRecursively() + } else { + throw Exception("Unsupported type of hkv store for data reset") + } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/context/UserContextLoader.kt b/src/main/kotlin/id/walt/webwallet/backend/context/UserContextLoader.kt new file mode 100644 index 0000000..156130e --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/context/UserContextLoader.kt @@ -0,0 +1,30 @@ +package id.walt.webwallet.backend.context + +import com.google.common.cache.CacheLoader +import id.walt.WALTID_DATA_ROOT +import id.walt.services.context.Context +import id.walt.services.hkvstore.FileSystemHKVStore +import id.walt.services.hkvstore.FilesystemStoreConfig +import id.walt.services.keystore.HKVKeyStoreService +import id.walt.services.vcstore.HKVVcStoreService +import java.security.MessageDigest + +object UserContextLoader : CacheLoader() { + + private fun hashString(input: String): String { + return MessageDigest + .getInstance("SHA-256") + .digest(input.toByteArray()) + .fold("", { str, it -> str + "%02x".format(it) }) + } + + override fun load(key: String): UserContext { + //TODO: get user context preferences from user database + return UserContext( + key, + HKVKeyStoreService(), + HKVVcStoreService(), + FileSystemHKVStore(FilesystemStoreConfig("${WALTID_DATA_ROOT}/data/${key}")) + ) + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/context/WalletContextManager.kt b/src/main/kotlin/id/walt/webwallet/backend/context/WalletContextManager.kt new file mode 100644 index 0000000..ca409b8 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/context/WalletContextManager.kt @@ -0,0 +1,23 @@ +package id.walt.webwallet.backend.context + +import com.google.common.cache.CacheBuilder +import com.google.common.cache.LoadingCache +import id.walt.services.context.Context +import id.walt.services.context.WaltIdContextManager +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserInfo +import io.javalin.http.Handler + +object WalletContextManager : WaltIdContextManager() { + + val userContexts: LoadingCache = CacheBuilder.newBuilder().maximumSize(256).build(UserContextLoader) + + fun getUserContext(userInfo: UserInfo) = userContexts.get(userInfo.id) + + val preRequestHandler + get() = Handler { ctx -> JWTService.getUserInfo(ctx)?.let { setCurrentContext(getUserContext(it)) } } + + val postRequestHandler + get() = Handler { ctx -> resetCurrentContext() } + +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcClient.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcClient.kt new file mode 100644 index 0000000..7904d7f --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcClient.kt @@ -0,0 +1,62 @@ +package id.walt.webwallet.backend.oidc + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY +import com.fasterxml.jackson.annotation.JsonProperty +import id.walt.webwallet.backend.oidc.requests.OidcRegisterRequest +import java.util.* + +data class OidcClient( + val _key: String, + + // REQUIRED + @JsonProperty("client_id") val clientId: String, + @JsonProperty("client_secret_expires_at") val clientSecretExpiresAt: Long, + @JsonProperty("redirect_uris") val redirectUris: List, + + // OPTIONAL + @JsonProperty("client_secret") @JsonInclude(NON_EMPTY) val clientSecret: String = "", + @JsonProperty("registration_access_token") @JsonInclude(NON_EMPTY) val registrationAccessToken: String = "", + @JsonProperty("registration_client_uri") @JsonInclude(NON_EMPTY) val registrationClientUri: String = "", + @JsonProperty("client_id_issued_at") @JsonInclude(NON_EMPTY) val clientIdIssuedAt: String = "", + + // Client Metadata - same as at : OidcRegisterRequest + @JsonProperty("application_type") val applicationType: String? = "", + @JsonProperty("client_name") val clientName: String? = "", + @JsonProperty("logo_uri") val logoUri: String? = "", + @JsonProperty("subject_type") val subjectType: String? = "", + @JsonProperty("sector_identifier_uri") val sectorIdentifierUri: String? = "", + @JsonProperty("token_endpoint_auth_method") val tokenEndpointAuthMethod: String? = "", + @JsonProperty("jwks_uri") val jwksUri: String? = "", + @JsonProperty("userinfo_encrypted_response_alg") val userinfoEncryptedResponseAlg: String? = "", + @JsonProperty("userinfo_encrypted_response_enc") val userinfoEncryptedResponseEnc: String? = "", + @JsonProperty("contacts") val contacts: List = emptyList(), + @JsonProperty("request_uris") val requestUris: List = emptyList() +) { + companion object { + fun newClient(req: OidcRegisterRequest): OidcClient { + val clientID = UUID.randomUUID().toString() + return OidcClient( + _key = clientID, + clientId = clientID, + clientSecretExpiresAt = 0, // will not expire + redirectUris = req.redirectUris, + clientSecret = UUID.randomUUID().toString(), + registrationAccessToken = "", + registrationClientUri = "", + clientIdIssuedAt = "", // TODO should be current date in following UTC format 1970-01-01T0:0:0Z + applicationType = req.applicationType, + clientName = req.clientName, + logoUri = req.logoUri, + subjectType = req.subjectType, + sectorIdentifierUri = req.sectorIdentifierUri, + tokenEndpointAuthMethod = req.tokenEndpointAuthMethod, + jwksUri = req.jwksUri, + userinfoEncryptedResponseAlg = req.userinfoEncryptedResponseAlg, + userinfoEncryptedResponseEnc = req.userinfoEncryptedResponseEnc, + contacts = req.contacts, + requestUris = req.requestUris + ) + } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcController.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcController.kt new file mode 100644 index 0000000..1651980 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcController.kt @@ -0,0 +1,125 @@ +package id.walt.webwallet.backend.oidc + +import id.walt.webwallet.backend.oidc.requests.* +import id.walt.webwallet.backend.oidc.responses.OidcTokenResponse +import id.walt.webwallet.backend.oidc.responses.OidcUserInfoResponse +import io.javalin.http.Context +import io.javalin.plugin.openapi.annotations.* +import io.javalin.plugin.openapi.dsl.document + +object OidcController { + + fun authorize(ctx: Context) { + ctx.redirect( + OidcService.authorize( + OidcAuthenticationRequest( + ctx.queryParam("scope"), + ctx.queryParam("response_type"), + ctx.queryParam("client_id"), + ctx.queryParam("redirect_uri"), + ctx.queryParam("state") + ) + ) + ) + } + + fun authorizeDocs() = document().operation { + it.summary("Authorization Endpoint").operationId("authorize").addTagsItem("OIDC") + .description( + "Example request would look like this: " + + "http://localhost:7000/oidc/authorize?response_type=code&scope=openid&client_id=s6BhdRkqt3&state=af0ifjsldkj&redirect_uri=http:%2F%2Flocalhost:63342%2Fletstrust-web%2Fsrc%2Fassets%2Fstatic%2Fdemo%2Fsuccess.html", + ) + }.pathParam("response_type").pathParam("scope").pathParam("client_id") + .pathParam("state").pathParam("redirect_uri").result("302") + + fun consent(ctx: Context) { + ctx.json(OidcService.consent(OidcConsentRequest("todo"))) + } + +// fun consentDocs() = document().operation { +// it.summary("Consent Endpoint").operationId("consent").addTagsItem("OIDC") +// }.body { it.description("Stores the consent and returns the code for fetching the ID token. Note that the redirect URL must point to the backend of the RP in order to conduct a call to the token endpoint.") } +// .json("200") + + fun token(ctx: Context) { + ctx.json( + OidcService.token( + OidcTokenRequest( + ctx.queryParam("grant_type") ?: "", + ctx.queryParam("code") ?: "", + ctx.queryParam("redirect_uri") ?: "" + ) + ) + ) + } + + fun tokenDocs() = document().operation { + it.summary("Token Endpoint").operationId("token").addTagsItem("OIDC") + }.body { + it.description("Endpoint for retrieving the ID token") + }.json("200") + + fun introspec(ctx: Context) { + ctx.json(OidcService.introspect(ctx.bodyAsClass(OidcIntrospecRequest::class.java))) + } + + fun introspecDocs() = document().operation { + it.summary("Introspect Endpoint").operationId("introspec").addTagsItem("OIDC") + }.body { + it.description("Endpoint for retrieving status of a given token") + }.result("200") + + @OpenApi( + summary = "Revocation Endpoint", + operationId = "revoke", + tags = ["OIDC"], + requestBody = OpenApiRequestBody( + [OpenApiContent(OidcConsentRequest::class)], + true, + "Endpoint for revoking a given token" + ), + security = [OpenApiSecurity("http")], + responses = [ + OpenApiResponse("200"), +// OpenApiResponse("400", [OpenApiContent(ErrorResponse::class)], "invalid request") + ] + ) + fun revoke(ctx: Context) { + val httpStatus: Int = OidcService.revoke(ctx.bodyAsClass(OidcRevokeRequest::class.java)) + ctx.status(httpStatus) + } + +// @OpenApi( +// summary = "Client Registration Endpoint", +// operationId = "register", +// tags = ["OIDC"], +// requestBody = OpenApiRequestBody( +// [OpenApiContent(OidcRegisterRequest::class)], +// true, +// "Endpoint for dynamic client registration" +// ), +// security = [OpenApiSecurity("http")], +// responses = [ +// OpenApiResponse("200", [OpenApiContent(OidcClient::class)], "successful"), +//// OpenApiResponse("400", [OpenApiContent(ErrorResponse::class)], "invalid request") +// ] +// ) +// fun register(ctx: Context) { +// ctx.json(OidcService.register(ctx.bodyAsClass(OidcRegisterRequest::class.java))) +// } + + @OpenApi( + summary = "UserInfo Endpoint", + operationId = "userinfo", + tags = ["OIDC"], + security = [OpenApiSecurity("http")], + responses = [ + OpenApiResponse("200", [OpenApiContent(OidcUserInfoResponse::class)], "successful"), +// OpenApiResponse("400", [OpenApiContent(ErrorResponse::class)], "invalid request") + ] + ) + fun userinfo(ctx: Context) { + ctx.json(OidcService.userinfo()) + //ctx.json(OidcService.userinfo(ctx.bodyAsClass(OidcUserInfoRequest::class.java))) + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcService.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcService.kt new file mode 100644 index 0000000..b58c58d --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/OidcService.kt @@ -0,0 +1,185 @@ +package id.walt.webwallet.backend.oidc + +import id.walt.webwallet.backend.oidc.requests.* +import id.walt.webwallet.backend.oidc.responses.OidcConsentResponse +import id.walt.webwallet.backend.oidc.responses.OidcTokenResponse +import id.walt.webwallet.backend.oidc.responses.OidcUserInfoResponse +import org.slf4j.LoggerFactory + + +object OidcService { + + private val log = LoggerFactory.getLogger(OidcService::class.java) + + //fun insertClient(client: OidcClient) = DatabaseManager.oidc.insertDocument(client) + + //fun getById(id: String) = DatabaseManager.oidc.getDocument(id, OidcClient::class.java) + + // TODO replace with session cache + var authReq: OidcAuthenticationRequest? = null + + //fun register(req: OidcRegisterRequest) = OidcClient.newClient(req).also { insertClient(it) } + + /** + * Returns the Authentication Endpoint or an Error Response URL + */ + fun authorize(req: OidcAuthenticationRequest): String { + log.info("Authentication Request: $req") + + // TODO check if user is authenticated already. if not -> login; else -> consent; + val authenticationResponse = "https://letstrust.id/user/login?scope=openid" + + // TODO error handling according to https://openid.net/specs/openid-connect-core-1_0.html#AuthError + // If the redirect_uri is valid, the error-codes will be sent to this page + val authenticationErrorResponse: String? = + null // e.g. HTTP/1.1 302 Found Location: https://client.example.org/cb?error=invalid_request&error_description=Unsupported%20response_type%20value&state=af0ifjsldkj + + // check if the client_id is valid + //val client = loadCheckedClient(req) + + // check if redirect_uri matches the pre-registered one + //if (client.redirectUris.contains(req.redirectUri)) throw java.lang.IllegalArgumentException("Invalid redirect URI") + + // TODO: check if the scope + response_type are valid + + // TODO: Should be stored in session-cache + authReq = req + + return authenticationErrorResponse ?: authenticationResponse + } + +// private fun loadCheckedClient(req: OidcAuthenticationRequest): OidcClient { +// if (req.clientId.isNullOrEmpty()) +// error("invalid client ID: ${req.clientId}") +// +// return getById(req.clientId!!) ?: error("Could not load OIDC client by ID ${req.clientId}") +// } + + fun consent(req: OidcConsentRequest): OidcConsentResponse { + // TODO generate code and store it within the user session + val code = "SplxlOBeZQQYbYS6WxSbIA" + val oidcAuthenticationResponseUrl = "${authReq!!.redirectUri}?code=${code}&state=${authReq!!.state}" + + log.info("Redirecting to: $oidcAuthenticationResponseUrl") + + // TODO consider error handling + return OidcConsentResponse(oidcAuthenticationResponseUrl) + } + + fun token(req: OidcTokenRequest): OidcTokenResponse { + // TODO Validate Token Request https://openid.net/specs/openid-connect-core-1_0.html#TokenRequestValidation + + // TODO validate client_id and client_secret + + // TODO validate code (may only be used once and has to expire within 10min) https://tools.ietf.org/html/rfc6749#section-4.1.2 + + // TODO validate and redirect_url + + // TODO error handling + + // TODO generate Access Token + + // TODO generate ID_TOKEN + + return OidcTokenResponse( + "SlAV32hkKG", + "Bearer", + "", + 3600, + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzcyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5NzAKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6qJp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJNqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7TpdQyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoSK5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg" + ) +// return mapOf( +// "id_token" to "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzcyI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZfV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5NzAKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6qJp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJNqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7TpdQyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoSK5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg", +// "access_token" to "SlAV32hkKG", +// "token_type" to "Bearer", +// "expires_in" to "3600" +// ) + } + + fun introspect(req: OidcIntrospecRequest): Map { + // TODO implement + return mapOf( + "active" to true, + "scope" to "read write email", + "client_id" to "SlAV32hkKG", + "username" to "phil", + "exp" to 1437275311, + ) + } + + fun revoke(req: OidcRevokeRequest): Int { + // TODO implement + return 200 + } + + // https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + fun userinfo(): OidcUserInfoResponse { //Map { + // TODO validate bearer token + + // TODO fetch user details from database + + // TODO Build and sign response according to https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse + + val resp = OidcUserInfoResponse("12349876") + + resp.setMap( + hashMapOf( + "email" to "dominik.beron@letstrust.io", + "email_verified" to true, + "picture" to "https://letstrust.id/assets/img/demo/profile_img.png", + "name" to "Dominik Beron", + + "address" to mapOf( + "street_address" to "123 Hollywood Blvd.", + "locality" to "Los Angeles", + "region" to "CA", + "postal_code" to "90210", + "country" to "US" + ), + + "skills" to mapOf( + "java" to listOf("1", "2"), // CredentialIDs should be UUIDs + "php" to listOf("3", "4"), + "oop" to listOf("1", "3") + ), + + "education" to listOf("5", "6"), + "work_history" to listOf("7", "8") + ) + ) + + /* + resp.add( + "address", mapOf( + "street_address" to "123 Hollywood Blvd.", + "locality" to "Los Angeles", + "region" to "CA", + "postal_code" to "90210", + "country" to "US" + ) + )*/ + + return resp + +// return mapOf( +// "sub" to "alice", +// "email" to "alice@wonderland.net", +// "email_verified" to true, +// "name" to "Alice Adams", +// "picture" to "https://c2id.com/users/alice.jpg", +// "address" to mapOf( +// "street_address" to "123 Hollywood Blvd.", +// "locality" to "Los Angeles", +// "region" to "CA", +// "postal_code" to "90210", +// "country" to "US"), +// "skills" to mapOf( +// "java" to listOf("1", "2"), // CredentialIDs should be UUIDs +// "php" to listOf("3", "4"), +// "oop" to listOf("1", "3") +// ), +// "education" to listOf("5", "6"), +// "work_history" to listOf("7", "8"), +// ) + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcAuthenticationRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcAuthenticationRequest.kt new file mode 100644 index 0000000..2eaacfb --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcAuthenticationRequest.kt @@ -0,0 +1,10 @@ +package id.walt.webwallet.backend.oidc.requests + +// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +data class OidcAuthenticationRequest( + var scope: String?, + var responseType: String?, + var clientId: String?, + var redirectUri: String?, + var state: String? +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcConsentRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcConsentRequest.kt new file mode 100644 index 0000000..f3e698d --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcConsentRequest.kt @@ -0,0 +1,5 @@ +package id.walt.webwallet.backend.oidc.requests + +data class OidcConsentRequest( + var todo: String +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcIntrospecRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcIntrospecRequest.kt new file mode 100644 index 0000000..ed84010 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcIntrospecRequest.kt @@ -0,0 +1,5 @@ +package id.walt.webwallet.backend.oidc.requests + +data class OidcIntrospecRequest( + val todo: String +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcRegisterRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcRegisterRequest.kt new file mode 100644 index 0000000..29ced4b --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcRegisterRequest.kt @@ -0,0 +1,19 @@ +package id.walt.webwallet.backend.oidc.requests + +import com.fasterxml.jackson.annotation.JsonProperty + +// https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata +data class OidcRegisterRequest( + @JsonProperty("application_type") val applicationType: String?, + @JsonProperty("redirect_uris") val redirectUris: List, + @JsonProperty("client_name") val clientName: String?, + @JsonProperty("logo_uri") val logoUri: String?, + @JsonProperty("subject_type") val subjectType: String?, + @JsonProperty("sector_identifier_uri") val sectorIdentifierUri: String?, + @JsonProperty("token_endpoint_auth_method") val tokenEndpointAuthMethod: String?, + @JsonProperty("jwks_uri") val jwksUri: String?, + @JsonProperty("userinfo_encrypted_response_alg") val userinfoEncryptedResponseAlg: String?, + @JsonProperty("userinfo_encrypted_response_enc") val userinfoEncryptedResponseEnc: String?, + @JsonProperty("contacts") val contacts: List = emptyList(), + @JsonProperty("request_uris") val requestUris: List = emptyList() +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcRevokeRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcRevokeRequest.kt new file mode 100644 index 0000000..0480c3f --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcRevokeRequest.kt @@ -0,0 +1,5 @@ +package id.walt.webwallet.backend.oidc.requests + +data class OidcRevokeRequest( + val todo: String +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcTokenRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcTokenRequest.kt new file mode 100644 index 0000000..6723763 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcTokenRequest.kt @@ -0,0 +1,7 @@ +package id.walt.webwallet.backend.oidc.requests + +data class OidcTokenRequest( + var grantType: String, + var code: String, + var redirectUri: String +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcUserInfoRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcUserInfoRequest.kt new file mode 100644 index 0000000..e2a6aad --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/requests/OidcUserInfoRequest.kt @@ -0,0 +1,5 @@ +package id.walt.webwallet.backend.oidc.requests + +data class OidcUserInfoRequest( + var todo: String +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcConsentResponse.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcConsentResponse.kt new file mode 100644 index 0000000..fe05d0d --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcConsentResponse.kt @@ -0,0 +1,3 @@ +package id.walt.webwallet.backend.oidc.responses + +data class OidcConsentResponse(val redirectUrl: String) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcTokenResponse.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcTokenResponse.kt new file mode 100644 index 0000000..1411823 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcTokenResponse.kt @@ -0,0 +1,13 @@ +package id.walt.webwallet.backend.oidc.responses + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY +import com.fasterxml.jackson.annotation.JsonProperty + +data class OidcTokenResponse( + @JsonProperty("access_token") val accessToken: String, + @JsonProperty("token_type") val tokenType: String, + @JsonProperty("refresh_token") @JsonInclude(NON_EMPTY) val refreshToken: String = "", + @JsonProperty("expires_in") val expiresIn: Int, + @JsonProperty("id_token") val idToken: String +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcUserInfoResponse.kt b/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcUserInfoResponse.kt new file mode 100644 index 0000000..0d83978 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/oidc/responses/OidcUserInfoResponse.kt @@ -0,0 +1,25 @@ +package id.walt.webwallet.backend.oidc.responses + + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonProperty + +class OidcUserInfoResponse(@JsonProperty("sub") val sub: String) { + + private var map = HashMap() + + @JsonAnySetter + fun add(key: String, value: Any) { + map[key] = value + } + + @JsonAnyGetter + fun getMap(): Map { + return map + } + + fun setMap(newMap: HashMap) { + map = newMap + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/rest/RestAPI.kt b/src/main/kotlin/id/walt/webwallet/backend/rest/RestAPI.kt new file mode 100644 index 0000000..e98fe4e --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/rest/RestAPI.kt @@ -0,0 +1,154 @@ +package id.walt.webwallet.backend.rest + +import cc.vileda.openapi.dsl.components +import cc.vileda.openapi.dsl.info +import cc.vileda.openapi.dsl.security +import com.beust.klaxon.Klaxon +import id.walt.WALTID_DATA_ROOT +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.credentials.w3c.templates.VcTemplateManager +import id.walt.customTemplates.EHIC +import id.walt.issuer.backend.IssuerController +import id.walt.multitenancy.MultitenancyController +import id.walt.onboarding.backend.OnboardingController +import id.walt.verifier.backend.VerifierController +import id.walt.webwallet.backend.auth.AuthController +import id.walt.webwallet.backend.wallet.DidWebRegistryController +import id.walt.webwallet.backend.wallet.WalletController +import io.javalin.Javalin +import io.javalin.apibuilder.ApiBuilder.path +import io.javalin.core.security.AccessManager +import io.javalin.core.util.RouteOverviewPlugin +import io.javalin.http.Context +import io.javalin.http.HttpCode +import io.javalin.plugin.json.JavalinJackson +import io.javalin.plugin.json.JsonMapper +import io.javalin.plugin.openapi.InitialConfigurationCreator +import io.javalin.plugin.openapi.OpenApiOptions +import io.javalin.plugin.openapi.OpenApiPlugin +import io.javalin.plugin.openapi.ui.ReDocOptions +import io.javalin.plugin.openapi.ui.SwaggerOptions +import io.ktor.http.* +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server +import mu.KotlinLogging + +object RestAPI { + + private val log = KotlinLogging.logger {} + + val DEFAULT_ROUTES = { + path("api") { + AuthController.routes + WalletController.routes + DidWebRegistryController.routes + MultitenancyController.routes + } + path("verifier-api") { + VerifierController.routes + } + path("issuer-api") { + IssuerController.routes + } + path("onboarding-api") { + OnboardingController.routes + } + } + + var apiTitle = "walt.id walletkit API" + + fun createJavalin(accessManager: AccessManager): Javalin = Javalin.create { config -> + + config.apply { + //enableDevLogging() + enableCorsForAllOrigins() + requestLogger { ctx, ms -> + if (ctx.url().endsWith("isVerified")) return@requestLogger + + log.debug { + StringBuilder("HTTP: ${ctx.method()} ${ctx.fullUrl()} - ${ctx.status()} in ${ms}ms") + .apply { + if (ctx.body().isNotEmpty()) append("\nRequest: ${ctx.body()}") + if (!ctx.resultString().isNullOrEmpty()) append("\nResponse: ${ctx.resultString()}") + + val location = ctx.res.getHeader(HttpHeaders.Location) + if (!location.isNullOrEmpty()) append("\nLocation: $location") + }.toString() + } + } + accessManager(accessManager) + registerPlugin(RouteOverviewPlugin("/api-routes")) + registerPlugin(OpenApiPlugin(OpenApiOptions(InitialConfigurationCreator { + OpenAPI().apply { + info { + title = apiTitle + } + servers = listOf(Server().url("/")) + components { + addSecuritySchemes("bearerAuth", SecurityScheme().apply { + name = "bearerAuth" + type = SecurityScheme.Type.HTTP + scheme = "bearer" + `in` = SecurityScheme.In.HEADER + bearerFormat = "JWT" + }) + } + security { + addList("bearerAuth") + } + } + }).apply { + path("/api/api-documentation") + swagger(SwaggerOptions("/api/swagger").title(apiTitle)) + reDoc(ReDocOptions("/api/redoc").title(apiTitle)) + })) + + this.jsonMapper(object : JsonMapper { + override fun toJsonString(obj: Any): String { + return Klaxon().toJsonString(obj) + } + + override fun fromJsonString(json: String, targetClass: Class): T & Any { + return JavalinJackson().fromJsonString(json, targetClass) + ?: throw IllegalArgumentException("Cannot deserialize JSON: $json") + } + }) + } + }.apply { + + fun Context.reportRequestException(exception: Exception): Context { + exception.printStackTrace() + return this.json( + mapOf( + "error" to true, + "error_type" to exception::class.simpleName, + "message" to exception.message, + "url" to this.fullUrl(), + "stacktrace" to exception.stackTraceToString() + ) + ) + .status(HttpCode.BAD_REQUEST) + } + + //exception(IllegalArgumentException::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(MismatchedInputException::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(JsonParseException::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(KlaxonException::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(InvalidKeyException::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(java.security.spec.InvalidKeySpecException::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(TenantNotFoundException::class.java) { e, ctx -> ctx.reportRequestException(e) } + exception(Exception::class.java) { e, ctx -> ctx.reportRequestException(e) } + //exception(Tenant.WaltContextTenantSystemError::class.java) { e, ctx -> ctx.reportRequestException(e) } + } + + fun start(bindAddress: String, port: Int, accessManager: AccessManager, routes: () -> Unit = DEFAULT_ROUTES): Javalin { + val javalin = createJavalin(accessManager) + javalin.routes(routes) + javalin.start(bindAddress, port) + println("web walletkit started at: http://$bindAddress:$port") + println("swagger docs are hosted at: http://$bindAddress:$port/api/swagger") + + return javalin + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/wallet/CredentialIssuance.kt b/src/main/kotlin/id/walt/webwallet/backend/wallet/CredentialIssuance.kt new file mode 100644 index 0000000..f490200 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/wallet/CredentialIssuance.kt @@ -0,0 +1,334 @@ +package id.walt.webwallet.backend.wallet + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.google.common.cache.LoadingCache +import com.nimbusds.openid.connect.sdk.token.OIDCTokens +import id.walt.common.VCObjectList +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.credentials.w3c.templates.VcTemplateManager +import id.walt.custodian.Custodian +import id.walt.model.DidMethod +import id.walt.model.DidUrl +import id.walt.model.dif.PresentationDefinition +import id.walt.model.oidc.* +import id.walt.services.context.ContextManager +import id.walt.services.oidc.OIDC4CIService +import id.walt.services.oidc.OIDCUtils +import id.walt.webwallet.backend.auth.UserInfo +import id.walt.webwallet.backend.config.WalletConfig +import id.walt.webwallet.backend.context.WalletContextManager +import io.javalin.http.BadRequestResponse +import io.javalin.http.InternalServerErrorResponse +import mu.KotlinLogging +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.Duration +import java.time.Instant +import java.util.* +import java.util.concurrent.* + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +data class CredentialIssuanceRequest( + val did: String, + val issuerId: String, + val credentialTypes: List, + val walletRedirectUri: String, +) + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +data class CredentialIssuance4PresentationRequest( + val did: String, + val issuerId: String, + val presentationSessionId: String, + val walletRedirectUri: String, +) + +data class CrossDeviceIssuanceInitiationRequest( + val oidcUri: String +) + +@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL) +data class CredentialIssuanceSession( + val id: String, + val issuerId: String, + val credentialTypes: List, + val isPreAuthorized: Boolean, + val isIssuerInitiated: Boolean, + val userPinRequired: Boolean, + @JsonIgnore val nonce: String, + var did: String? = null, + var walletRedirectUri: String? = null, + @JsonIgnore var user: UserInfo? = null, + @JsonIgnore var tokens: OIDCTokens? = null, + @JsonIgnore var lastTokenUpdate: Instant? = null, + @JsonIgnore var tokenNonce: String? = null, + @JsonIgnore var preAuthzCode: String? = null, + @JsonIgnore var opState: String? = null, + @VCObjectList var credentials: List? = null +) { + companion object { + fun fromIssuanceRequest(credentialIssuanceRequest: CredentialIssuanceRequest): CredentialIssuanceSession { + return CredentialIssuanceSession( + id = UUID.randomUUID().toString(), + issuerId = credentialIssuanceRequest.issuerId, + credentialTypes = credentialIssuanceRequest.credentialTypes, + false, false, false, + nonce = UUID.randomUUID().toString(), + did = credentialIssuanceRequest.did, + walletRedirectUri = credentialIssuanceRequest.walletRedirectUri + ) + } + + fun fromInitiationRequest(issuanceInitiationRequest: IssuanceInitiationRequest): CredentialIssuanceSession { + return CredentialIssuanceSession( + id = UUID.randomUUID().toString(), + issuerId = issuanceInitiationRequest.issuer_url, + credentialTypes = issuanceInitiationRequest.credential_types, + isPreAuthorized = issuanceInitiationRequest.isPreAuthorized, + isIssuerInitiated = true, + userPinRequired = issuanceInitiationRequest.user_pin_required, + nonce = UUID.randomUUID().toString(), + opState = issuanceInitiationRequest.op_state, + preAuthzCode = issuanceInitiationRequest.pre_authorized_code + ) + } + } +} + +object CredentialIssuanceManager { + + private val log = KotlinLogging.logger { } + + val EXPIRATION_TIME = Duration.ofMinutes(5) + val sessionCache = CacheBuilder.newBuilder().expireAfterAccess(EXPIRATION_TIME.seconds, TimeUnit.SECONDS) + .build() + val issuerCache: LoadingCache = CacheBuilder.newBuilder().maximumSize(256) + .build( + CacheLoader.from { issuerId -> // find issuer from config + log.debug { "Loading issuer: $issuerId, got: ${WalletConfig.config.issuers[issuerId!!]}" } + (WalletConfig.config.issuers[issuerId!!] + ?: OIDCProvider(issuerId, issuerId) // else, assume issuerId is a valid issuer url + ).let { + + log.debug { "Retrieving metadata endpoint for issuer: ${OIDC4CIService.getMetadataEndpoint(it)}" } + + OIDC4CIService.getWithProviderMetadata(it) + + } + } + ) + + val redirectURI: URI + get() = URI.create("${WalletConfig.config.walletApiUrl}/siop/finalizeIssuance") + + private fun getPreferredFormat( + credentialTypeId: String, + did: String, + supportedCredentials: Map + ): String? { + val preferredByEcosystem = when (DidUrl.from(did).method) { + DidMethod.iota.name -> "ldp_vc" + DidMethod.ebsi.name -> "jwt_vc" + else -> "jwt_vc" + } + log.debug { "Checking if $credentialTypeId is supported (supported: $supportedCredentials)" } + if (supportedCredentials.containsKey(credentialTypeId)) { + log.debug { "Credential $credentialTypeId is supported!" } + log.debug { "Credential: ${supportedCredentials[credentialTypeId]}" } + + if (!supportedCredentials[credentialTypeId]!!.formats.containsKey(preferredByEcosystem)) { + log.debug { "Format $preferredByEcosystem is supported (${supportedCredentials[credentialTypeId]!!.formats})" } + // ecosystem preference is explicitly not supported, check if ldp_vc or jwt_vc is + return supportedCredentials[credentialTypeId]!!.formats.keys.firstOrNull { fmt -> + setOf( + "jwt_vc", + "ldp_vc" + ).contains(fmt) + } + } else { + log.debug { "Format $preferredByEcosystem is NOT supported (${supportedCredentials[credentialTypeId]!!.formats})" } + } + } + return preferredByEcosystem + } + + fun executeAuthorizationStep(session: CredentialIssuanceSession): URI { + val issuer = issuerCache[session.issuerId] + + val supportedCredentials = OIDC4CIService.getSupportedCredentials(issuer) + val credentialDetails = session.credentialTypes.map { + CredentialAuthorizationDetails( + credential_type = it, + format = getPreferredFormat(it, session.did!!, supportedCredentials) + ) + } + + return OIDC4CIService.executePushedAuthorizationRequest( + issuer = issuer, + redirectUri = redirectURI, + credentialDetails = credentialDetails, + nonce = session.nonce, + state = session.id, + wallet_issuer = WalletConfig.config.walletApiUrl, + user_hint = URI.create(WalletConfig.config.walletUiUrl).authority, + op_state = session.opState + ) ?: throw InternalServerErrorResponse("Could not execute pushed authorization request on issuer") + } + + fun startIssuance(issuanceRequest: CredentialIssuanceRequest, user: UserInfo): URI { + + val session = CredentialIssuanceSession.fromIssuanceRequest(issuanceRequest).apply { + this.user = user + } + return executeAuthorizationStep(session).also { + putSession(session) + } + } + + fun startIssuanceForPresentation( + issuance4PresentationRequest: CredentialIssuance4PresentationRequest, + user: UserInfo + ): URI { + val presentationSession = + CredentialPresentationManager.getPresentationSession(issuance4PresentationRequest.presentationSessionId) + ?: throw BadRequestResponse("No presentation session found for this session id") + return startIssuance( + CredentialIssuanceRequest( + did = issuance4PresentationRequest.did, + issuerId = issuance4PresentationRequest.issuerId, + credentialTypes = getIssuerCredentialTypesFor( + presentationSession.sessionInfo.presentationDefinition, + issuance4PresentationRequest.issuerId + ), + walletRedirectUri = issuance4PresentationRequest.walletRedirectUri + ), + user + ) + } + + fun startIssuerInitiatedIssuance(issuanceInitiationRequest: IssuanceInitiationRequest): String { + val session = CredentialIssuanceSession.fromInitiationRequest(issuanceInitiationRequest) + putSession(session) + return session.id + } + + fun continueIssuerInitiatedIssuance( + sessionId: String, + did: String, + user: UserInfo, + userPin: String? + ): CredentialIssuanceSession { + val session = sessionCache.getIfPresent(sessionId) ?: throw BadRequestResponse("Session invalid or not found") + if (!session.isIssuerInitiated) throw BadRequestResponse("Session is not issuer initiated") + session.did = did + session.user = user + putSession(session) + if (session.isPreAuthorized) { + return finalizeIssuance(sessionId, session.preAuthzCode!!, userPin) + } + return session + } + + private fun enc(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + fun finalizeIssuance(id: String, code: String, userPin: String? = null): CredentialIssuanceSession { + val session = sessionCache.getIfPresent(id) ?: throw BadRequestResponse("Session invalid or not found") + val issuer = issuerCache[session.issuerId] + val user = session.user ?: throw BadRequestResponse("Session has not been confirmed by user") + val did = session.did ?: throw BadRequestResponse("No DID assigned to session") + val redirectUriString = if (!session.isPreAuthorized) redirectURI.toString() else null + val tokenResponse = + OIDC4CIService.getAccessToken(issuer, code, redirectUriString, session.isPreAuthorized, userPin) + if (!tokenResponse.indicatesSuccess()) { + return session + } + session.tokens = tokenResponse.toSuccessResponse().oidcTokens + session.lastTokenUpdate = Instant.now() + tokenResponse.customParameters["c_nonce"]?.toString()?.also { + session.tokenNonce = it + } + + val supportedCredentials = OIDC4CIService.getSupportedCredentials(issuer) + // log.info { "Supported credentials are: $supportedCredentials" } + + ContextManager.runWith(WalletContextManager.getUserContext(user)) { + val custodian = Custodian.getService() + + session.credentials = session.credentialTypes.mapNotNull { typeId -> + OIDC4CIService.getCredential( + issuer, + session.tokens!!.accessToken, + typeId, + OIDC4CIService.generateDidProof(issuer, did, session.tokenNonce ?: ""), + format = getPreferredFormat(typeId, did, supportedCredentials) + ) + }.onEach { + it.id = it.id ?: UUID.randomUUID().toString() + custodian.storeCredential(it.id!!, it) + } + } + + putSession(session) + return session + } + + fun getSession(id: String): CredentialIssuanceSession? { + return sessionCache.getIfPresent(id) + } + + fun putSession(session: CredentialIssuanceSession) { + sessionCache.put(session.id, session) + } + + fun getIssuerWithMetadata(issuerId: String): OIDCProviderWithMetadata { + return issuerCache[issuerId] + } + + fun findIssuersFor(presentationDefinition: PresentationDefinition): List { + val matchingTemplates = findMatchingVCTemplates(presentationDefinition) + + return WalletConfig.config.issuers.keys.map { issuerCache[it] }.filter { issuer -> + log.info { "Finding issuer for: ${issuer.id} (url = ${issuer.url})" } + val supportedTypeLists = OIDC4CIService.getSupportedCredentials(issuer).values + .flatMap { credentialMetadata -> credentialMetadata.formats.values } + .map { fmt -> fmt.types } + log.info { "Got supported type list: $supportedTypeLists" } + matchingTemplates.map { it.type }.all { reqTypeList -> + supportedTypeLists.any { typeList -> + reqTypeList.size == typeList.size && + reqTypeList.zip(typeList).all { (x, y) -> x == y } + } + } + }.map { + OIDCProvider(it.id, it.url, it.description) // strip secrets + } + } + + private fun findMatchingVCTemplates(presentationDefinition: PresentationDefinition): List { + return VcTemplateManager.listTemplates() + .map { VcTemplateManager.getTemplate(it.name, true).template!! } + .filter { tmpl -> + presentationDefinition.input_descriptors.any { inputDescriptor -> + OIDCUtils.matchesInputDescriptor(tmpl, inputDescriptor) + } + } + } + + private fun getIssuerCredentialTypesFor(presentationDefinition: PresentationDefinition, issuerId: String): List { + val issuer = issuerCache[issuerId] + val reqTypeLists = findMatchingVCTemplates(presentationDefinition).map { it.type } + val supportedCredentials = OIDC4CIService.getSupportedCredentials(issuer) + return supportedCredentials.filter { entry -> + entry.value.formats.values.map { it.types }.any { typeList -> + reqTypeLists.any { reqTypeList -> + reqTypeList.size == typeList.size && + reqTypeList.zip(typeList).all { (x, y) -> x == y } + } + } + }.map { it.key } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/wallet/CredentialPresentation.kt b/src/main/kotlin/id/walt/webwallet/backend/wallet/CredentialPresentation.kt new file mode 100644 index 0000000..aeef9af --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/wallet/CredentialPresentation.kt @@ -0,0 +1,142 @@ +package id.walt.webwallet.backend.wallet + +import com.beust.klaxon.Json +import com.google.common.cache.CacheBuilder +import com.nimbusds.oauth2.sdk.AuthorizationRequest +import com.nimbusds.oauth2.sdk.ResponseMode +import id.walt.common.KlaxonWithConverters +import id.walt.credentials.w3c.templates.VcTemplateManager +import id.walt.credentials.w3c.toVerifiablePresentation +import id.walt.custodian.Custodian +import id.walt.model.dif.InputDescriptor +import id.walt.model.dif.PresentationDefinition +import id.walt.model.oidc.OIDCProvider +import id.walt.model.oidc.SIOPv2Response +import id.walt.services.oidc.OIDC4VPService +import id.walt.services.oidc.OIDCUtils +import java.time.Duration +import java.util.* +import java.util.concurrent.* + +data class CredentialPresentationSessionInfo( + val id: String, + val presentationDefinition: PresentationDefinition, + val redirectUri: String, + var did: String? = null, + var presentableCredentials: List? = null, + var availableIssuers: List? = null +) + +data class CredentialPresentationSession( + val id: String, + @Json(ignored = true) val req: AuthorizationRequest, + val sessionInfo: CredentialPresentationSessionInfo +) + +data class PresentableCredential( + val credentialId: String, + val claimId: String? +) + +data class PresentationResponse( + val vp_token: String, + val presentation_submission: String, + val id_token: String?, + val state: String?, + val fulfilled: Boolean, + val rp_response: String? +) { + companion object { + fun fromSiopResponse(siopResp: SIOPv2Response, fulfilled: Boolean, rp_response: String?): PresentationResponse { + return PresentationResponse( + OIDCUtils.toVpToken(siopResp.vp_token), + KlaxonWithConverters().toJsonString(siopResp.presentation_submission), + siopResp.id_token, + siopResp.state, fulfilled, rp_response + ) + } + } +} + +object CredentialPresentationManager { + val EXPIRATION_TIME = Duration.ofMinutes(5) + val sessionCache = CacheBuilder.newBuilder().expireAfterAccess(EXPIRATION_TIME.seconds, TimeUnit.SECONDS) + .build() + + fun initCredentialPresentation(siopReq: AuthorizationRequest): CredentialPresentationSession { + val id = UUID.randomUUID().toString() + return CredentialPresentationSession( + id = id, + req = siopReq, + sessionInfo = CredentialPresentationSessionInfo( + id, + presentationDefinition = OIDC4VPService.getPresentationDefinition(siopReq), + redirectUri = siopReq.redirectionURI.toString() + ) + ).also { + sessionCache.put(it.id, it) + } + } + + private fun getPresentableCredentials(session: CredentialPresentationSession): List { + return OIDCUtils.findCredentialsFor(session.sessionInfo.presentationDefinition, session.sessionInfo.did) + .flatMap { kv -> + kv.value.map { credId -> PresentableCredential(credId, kv.key) } + }.toList() + } + + private fun getRequiredSchemaIds(input_descriptors: List): Set { + return VcTemplateManager.listTemplates().map { tmpl -> VcTemplateManager.getTemplate(tmpl.name, true).template!! } + .filter { templ -> input_descriptors.any { indesc -> OIDCUtils.matchesInputDescriptor(templ, indesc) } } + .map { templ -> templ.credentialSchema?.id } + .filterNotNull() + .toSet() + } + + fun continueCredentialPresentationFor(sessionId: String, did: String): CredentialPresentationSession { + val session = + sessionCache.getIfPresent(sessionId) ?: throw IllegalArgumentException("No session found for id $sessionId") + session.sessionInfo.did = did + session.sessionInfo.presentableCredentials = getPresentableCredentials(session) + session.sessionInfo.availableIssuers = null + if (session.sessionInfo.presentableCredentials!!.isEmpty()) { + if (session.sessionInfo.presentationDefinition.input_descriptors.isNotEmpty()) { + // credentials are required, but no suitable ones are found + session.sessionInfo.availableIssuers = + CredentialIssuanceManager.findIssuersFor(session.sessionInfo.presentationDefinition) + } + } + + return session + } + + fun fulfillPresentation(sessionId: String, selectedCredentials: List): PresentationResponse { + val session = + sessionCache.getIfPresent(sessionId) ?: throw IllegalArgumentException("No session found for id $sessionId") + val did = session.sessionInfo.did ?: throw IllegalArgumentException("Did not set for this session") + + val myCredentials = Custodian.getService().listCredentials() + val selectedCredentialIds = selectedCredentials.map { cred -> cred.credentialId }.toSet() + val selectedCredentials = + myCredentials.filter { cred -> selectedCredentialIds.contains(cred.id) }.map { cred -> cred.encode() } + .toList() + val vp = Custodian.getService().createPresentation( + selectedCredentials, + did, + null, + challenge = session.req.getCustomParameter("nonce")?.firstOrNull(), + expirationDate = null + ).toVerifiablePresentation() + + val siopResponse = OIDC4VPService.getSIOPResponseFor(session.req, did, listOf(vp)) + val rp_response = if (ResponseMode("post") == session.req.responseMode) { + OIDC4VPService.postSIOPResponse(session.req, siopResponse) + } else null + + return PresentationResponse.fromSiopResponse(siopResponse, rp_response != null, rp_response) + } + + fun getPresentationSession(id: String): CredentialPresentationSession? { + return sessionCache.getIfPresent(id) + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/wallet/DidCreationRequest.kt b/src/main/kotlin/id/walt/webwallet/backend/wallet/DidCreationRequest.kt new file mode 100644 index 0000000..483c438 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/wallet/DidCreationRequest.kt @@ -0,0 +1,13 @@ +package id.walt.webwallet.backend.wallet + +import com.beust.klaxon.Json +import id.walt.model.DidMethod + +data class DidCreationRequest( + val method: DidMethod = DidMethod.key, + @Json(serializeNull = false) val keyId: String? = null, + @Json(serializeNull = false) val didEbsiBearerToken: String? = null, + @Json(serializeNull = false) val didWebDomain: String? = null, + @Json(serializeNull = false) val didWebPath: String? = null, + @Json(serializeNull = false) val didEbsiVersion: Int = 1, +) diff --git a/src/main/kotlin/id/walt/webwallet/backend/wallet/DidWebRegistryController.kt b/src/main/kotlin/id/walt/webwallet/backend/wallet/DidWebRegistryController.kt new file mode 100644 index 0000000..d6dca8b --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/wallet/DidWebRegistryController.kt @@ -0,0 +1,92 @@ +package id.walt.webwallet.backend.wallet + +import id.walt.WALTID_DATA_ROOT +import id.walt.model.Did +import id.walt.model.DidMethod +import id.walt.model.did.DidWeb +import id.walt.services.context.ContextManager +import id.walt.services.did.DidService +import id.walt.services.hkvstore.FileSystemHKVStore +import id.walt.services.hkvstore.FilesystemStoreConfig +import id.walt.services.keystore.HKVKeyStoreService +import id.walt.services.vcstore.HKVVcStoreService +import id.walt.webwallet.backend.config.WalletConfig +import id.walt.webwallet.backend.context.UserContext +import io.javalin.apibuilder.ApiBuilder.get +import io.javalin.apibuilder.ApiBuilder.path +import io.javalin.http.BadRequestResponse +import io.javalin.http.Context +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object DidWebRegistryController { + val routes + get() = path("did-registry") { + path("{id}/did.json") { + get("", documented( + document().operation { + it.summary("Load did web") + .addTagsItem("did-web") + .operationId("loadDid") + } + .json("200"), + DidWebRegistryController::loadDidWeb + )) + } + } + + val didRegistryContext = UserContext( + contextId = "did registry", + hkvStore = FileSystemHKVStore(FilesystemStoreConfig("$WALTID_DATA_ROOT/data/did-registry")), + keyStore = HKVKeyStoreService(), + vcStore = HKVVcStoreService() + ) + + val domain + get() = URI.create(WalletConfig.config.walletApiUrl).authority + + val domainDidPart + get() = domain.let { + URLEncoder.encode(it, StandardCharsets.UTF_8) + } + + val rootPath = "api/did-registry" + val rootPathDidPart = "api:did-registry" + + private fun loadDidWeb(ctx: Context) { + val id = ctx.pathParam("id") + ContextManager.runWith(didRegistryContext) { + try { + ctx.json( + DidService.load( + "did:web:$domainDidPart:$rootPathDidPart:${id}" + ) + ) + } catch (e: Exception) { + e.printStackTrace() + ctx.status(404) + } + } + } + + fun registerDidWeb(ctx: Context) { + val did = Did.decode(ctx.body()) ?: throw BadRequestResponse("Could not parse DID") + if (did.method != DidMethod.web) throw BadRequestResponse("DID must be of type did:web") + val match = + "did:web:${Regex.escape(domainDidPart)}:${Regex.escape(rootPathDidPart)}:([^:]+)".toRegex().matchEntire(did.id) + ?: throw BadRequestResponse("did:web doesn't match this registry domain and path") + val id = match.groups[1]!!.value + + ContextManager.runWith(didRegistryContext) { + if (DidService.listDids().any { d -> d == did.id }) throw BadRequestResponse("DID already registered") + + DidService.storeDid(did.id, did.encode()) + DidService.importKeys(did.id) + + + } + } +} diff --git a/src/main/kotlin/id/walt/webwallet/backend/wallet/WalletController.kt b/src/main/kotlin/id/walt/webwallet/backend/wallet/WalletController.kt new file mode 100644 index 0000000..8c9de27 --- /dev/null +++ b/src/main/kotlin/id/walt/webwallet/backend/wallet/WalletController.kt @@ -0,0 +1,641 @@ +package id.walt.webwallet.backend.wallet + +import com.nimbusds.oauth2.sdk.ResponseType +import com.nimbusds.oauth2.sdk.Scope +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.oauth2.sdk.util.URLUtils +import com.nimbusds.openid.connect.sdk.OIDCScopeValue +import com.nimbusds.openid.connect.sdk.SubjectType +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import id.walt.common.KlaxonWithConverters +import id.walt.common.prettyPrint +import id.walt.credentials.w3c.toVerifiableCredential +import id.walt.crypto.KeyAlgorithm +import id.walt.custodian.Custodian +import id.walt.model.DidMethod +import id.walt.model.DidUrl +import id.walt.model.oidc.IssuanceInitiationRequest +import id.walt.rest.core.DidController +import id.walt.rest.custodian.CustodianController +import id.walt.services.context.ContextManager +import id.walt.services.did.DidService +import id.walt.services.ecosystems.essif.EssifClient +import id.walt.services.ecosystems.essif.didebsi.DidEbsiService +import id.walt.services.key.KeyService +import id.walt.services.oidc.OIDC4VPService +import id.walt.signatory.ProofConfig +import id.walt.signatory.Signatory +import id.walt.signatory.dataproviders.MergingDataProvider +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserRole +import id.walt.webwallet.backend.config.WalletConfig +import id.walt.webwallet.backend.context.UserContext +import id.walt.webwallet.backend.context.WalletContextManager +import io.ipfs.multibase.Multibase +import io.javalin.apibuilder.ApiBuilder.* +import io.javalin.http.BadRequestResponse +import io.javalin.http.ContentType +import io.javalin.http.Context +import io.javalin.http.HttpCode +import io.javalin.plugin.openapi.dsl.document +import io.javalin.plugin.openapi.dsl.documented +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.util.* + +object WalletController { + val routes + get() = path("") { + path("wallet") { + path("did") { + // list DIDs + path("list") { + get( + documented(document().operation { + it.summary("List DIDs").operationId("listDids").addTagsItem("Wallet / DIDs") + } + .jsonArray("200"), + WalletController::listDids + ), UserRole.AUTHORIZED + ) + } + path("ebsiVersion") { + get( + documented(document().operation { + it.summary("Get version of did:ebsi").addTagsItem("Wallet / DIDs") + }.queryParam("did").result("200"), WalletController::ebsiVersion), + UserRole.AUTHORIZED + ) + } + // load DID + get("{id}", documented(document().operation { + it.summary("Load DID").operationId("load").addTagsItem("Wallet / DIDs") + } + .json("200"), DidController::load), UserRole.AUTHORIZED) + // create new DID + path("create") { + post( + documented(document().operation { + it.summary("Create new DID") + .description("Creates and registers a DID. Currently the DID methods: key, web, ebsi (v1/v2) and iota are supported. For EBSI v1: a bearer token is required.") + .operationId("createDid").addTagsItem("Wallet / DIDs") + } + .body() + .result("200"), + WalletController::createDid + ), UserRole.AUTHORIZED + ) + } + } + path("credentials") { + get( + "list", + documented(CustodianController.listCredentialIdsDocs(), CustodianController::listCredentials), + UserRole.AUTHORIZED + ) + delete( + "delete/{alias}", + documented(CustodianController.deleteCredentialDocs(), CustodianController::deleteCredential), + UserRole.AUTHORIZED + ) + put( + "{alias}", + documented(CustodianController.storeCredentialsDocs(), CustodianController::storeCredential), + UserRole.AUTHORIZED + ) + } + path("keys") { + get( + "list", + documented(CustodianController.listKeysDocs(), CustodianController::listKeys), + UserRole.AUTHORIZED + ) + post( + "import", + documented(CustodianController.importKeysDocs(), CustodianController::importKey), + UserRole.AUTHORIZED + ) + delete( + "delete/{id}", + documented(CustodianController.deleteKeysDocs(), CustodianController::deleteKey), + UserRole.AUTHORIZED + ) + post( + "export", + documented(CustodianController.exportKeysDocs(), CustodianController::exportKey), + UserRole.AUTHORIZED + ) + } + path("presentation") { + // called by wallet UI + post("startPresentation", documented( + document().operation { + it.summary("Start a presentation session from an OIDC URL, that could be scanned from a QR code (cross device)") + .addTagsItem("Wallet / Presentation") + .operationId("startPresentation") + } + .body() + .result("200"), + WalletController::startPresentationFromUri + ), UserRole.AUTHORIZED) + get("continue", documented( + document().operation { + it.summary("Continue presentation requested by verifier, returns CredentialPresentationSession") + .operationId("continuePresentation") + .addTagsItem("Wallet / Presentation") + } + .queryParam("sessionId") + .queryParam("did") + .result("200", "application/json", applyUpdates = null), + WalletController::continuePresentation + ), UserRole.AUTHORIZED) + // called by wallet UI + post("fulfill", documented( + document().operation { + it.summary("Fulfill credentials presentation with selected credentials") + .operationId("fulfillPresentation") + .addTagsItem("Wallet / Presentation") + } + .queryParam("sessionId") + .body>() + .json("200"), + WalletController::fulfillPresentation + ), UserRole.AUTHORIZED) + } + path("issuer") { + get( + "list", documented( + document().operation { + it.summary("List known credential issuer (portals)").addTagsItem("Wallet / Issuers") + .operationId("listIssuers") + }, + WalletController::listIssuers + ), + UserRole.UNAUTHORIZED + ) + get("metadata", documented( + document().operation { + it.summary("View credential issuer (portal) meta data").addTagsItem("Wallet / Issuers") + .operationId("issuerMeta") + } + .queryParam("issuerId"), + WalletController::issuerMeta), + UserRole.UNAUTHORIZED) + } + path("issuance") { + post("start", documented( + document().operation { + it.summary("Initialize credential issuance from selected issuer").addTagsItem("Wallet / Issuance") + .operationId("initIssuance") + } + .body() + .result("200"), + WalletController::startIssuance + ), UserRole.AUTHORIZED) + post("startForPresentation", documented( + document().operation { + it.summary("Initialize credential issuance from selected issuer").addTagsItem("Wallet / Issuance") + .operationId("initIssuance") + } + .body() + .result("200"), + WalletController::startIssuanceForPresentation + ), UserRole.AUTHORIZED) + // called by wallet UI + get("info", documented( + document().operation { + it.summary("Get issuance session info, including issued credentials") + .addTagsItem("Wallet / Issuance") + .operationId("issuanceSessionInfo") + } + .queryParam("sessionId") + .json("200"), + WalletController::getIssuanceSessionInfo + ), UserRole.AUTHORIZED) + post("startIssuerInitiatedIssuance", documented( + document().operation { + it.summary("Start an issuer-initiated issuance session from an OIDC URL, that could be scanned from a QR code (cross device)") + .addTagsItem("Wallet / Issuance") + .operationId("startIssuerInitiatedIssuance") + } + .body() + .result("200"), + WalletController::startIssuerInitiatedIssuance + ), UserRole.AUTHORIZED) + get("continueIssuerInitiatedIssuance", documented( + document().operation { + it.summary("Continue an issuer-initiated issuance session, after user accepted the issuance request") + .addTagsItem("Wallet / Issuance") + .operationId("continueIssuerInitiatedIssuance") + } + .queryParam("sessionId") + .queryParam("did") + .queryParam("userPin") + .result("200"), + WalletController::continueIssuerInitiatedIssuance + ), UserRole.AUTHORIZED) + } + path("onboard") { + path("gaiax/{did}") { + post( + documented(document().operation { + it.summary("Onboard legal person") + .description("Creates a gaia-x compliant credential from the given self-description.") + .operationId("onboardGaiaX").addTagsItem("Wallet / DID Onboarding") + } + .body() + .result("200"), + WalletController::onboardGaiaX + ), UserRole.AUTHORIZED + ) + } + } + path("oidc") { + get("detectRequestType", documented(document().operation { + it.summary("Detect OIDC request type") + .description("Detect OIDC request type: initiate-issuance, presentation") + .operationId("detectRequestType").addTagsItem("Wallet / OIDC") + } + .queryParam("uri") + .result("200"), WalletController::detectOIDCRequestType + ), UserRole.AUTHORIZED) + } + post( + "resetUserData", + documented( + document().operation { it.summary("Reset all user data").addTagsItem("Wallet / Account") }, + WalletController::resetUserData + ), + UserRole.AUTHORIZED + ) + } + path("siop") { + get(".well-known/openid-configuration", documented( + document().operation { + it.summary("get OIDC provider meta data") + .addTagsItem("SIOP") + .operationId("oidcProviderMeta") + } + .json("200"), + WalletController::oidcProviderMeta + )) + // called from EXTERNAL verifier + get("initiatePresentation", documented( + document().operation { + it.summary("Parse siop request from URL query parameters") + .operationId("initPresentation") + .addTagsItem("SIOP").addTagsItem("OIDC4VP") + } + .queryParam("response_type") + .queryParam("client_id") + .queryParam("redirect_uri") + .queryParam("scope") + .queryParam("state") + .queryParam("nonce") + .queryParam("registration") + .queryParam("exp") + .queryParam("iat") + .queryParam("claims") + .result("302"), + WalletController::initCredentialPresentation + ), UserRole.UNAUTHORIZED) + // called from EXTERNAL issuer / user-agent + get("initiateIssuance", documented( + document().operation { + it.summary("Issuance initiation (OIDC4VCI) endpoint").addTagsItem("SIOP").addTagsItem("OIDC4VCI") + .operationId("initiateIssuance") + } + .queryParam("issuer") + .queryParam("credential_type", isRepeatable = true) + .queryParam("pre-authorized_code") + .queryParam("user_pin_required") + .queryParam("op_state") + .result("200"), + WalletController::initiateIssuance + ), UserRole.UNAUTHORIZED) + // called from EXTERNAL issuer / user-agent + get("finalizeIssuance", documented( + document().operation { + it.summary("Finalize credential issuance").addTagsItem("SIOP").addTagsItem("OIDC4VCI") + .operationId("finalizeIssuance") + } + .queryParam("code") + .queryParam("state") + .result("302"), + WalletController::finalizeIssuance + ), UserRole.UNAUTHORIZED) + } + } + + private fun resetUserData(context: Context) { + (WalletContextManager.currentContext as UserContext).resetAllData() + } + + private fun ebsiVersion(context: Context) { + val did = context.queryParam("did") ?: throw BadRequestResponse("Missing did parameter") + if (DidUrl.isDidUrl(did) && DidUrl.from(did).method == "ebsi") { + context.result(Multibase.decode(DidUrl.from(did).identifier).first().toInt().toString()) + } else { + throw BadRequestResponse("Invalid did:ebsi") + } + } + + fun oidcProviderMeta(ctx: Context) { + ctx.json( + OIDCProviderMetadata( + Issuer(WalletConfig.config.walletUiUrl), + listOf(SubjectType.PAIRWISE), + URI.create("") + ).apply { + authorizationEndpointURI = URI("${WalletConfig.config.walletApiUrl}/siop/initiatePresentation") + setCustomParameter("initiate_issuance_endpoint", "${WalletConfig.config.walletApiUrl}/siop/initiateIssuance") + scopes = Scope(OIDCScopeValue.OPENID) + responseTypes = listOf(ResponseType.IDTOKEN, ResponseType("vp_token")) + + }.toJSONObject() + ) + } + + fun listDids(ctx: Context) { + ctx.json(DidService.listDids()) + } + + fun loadDid(ctx: Context) { + val id = ctx.pathParam("id") + ctx.json(DidService.load(id)) + } + + fun createDid(ctx: Context) { + val req = ctx.bodyAsClass() + + val keyId = req.keyId?.let { KeyService.getService().load(it).keyId.id } + + when (req.method) { + DidMethod.ebsi -> { + if (req.didEbsiVersion == 1 && req.didEbsiBearerToken.isNullOrEmpty()) { + ctx.status(HttpCode.BAD_REQUEST) + .result("ebsiBearerToken form parameter is required for EBSI DID v1 registration.") + return + } + + val did = + DidService.create( + req.method, + keyId ?: KeyService.getService().generate(KeyAlgorithm.ECDSA_Secp256k1).id, + DidService.DidEbsiOptions(version = req.didEbsiVersion) + ) + if (req.didEbsiVersion == 1) { + EssifClient.onboard(did, req.didEbsiBearerToken) + EssifClient.authApi(did) + DidEbsiService.getService().registerDid(did, did) + } + ctx.result(did) + } + + DidMethod.web -> { + val didRegistryAuthority = URI.create(WalletConfig.config.walletApiUrl).authority + val didDomain = req.didWebDomain.orEmpty().ifEmpty { didRegistryAuthority } + + + val didWebKeyId = keyId ?: KeyService.getService().generate(KeyAlgorithm.EdDSA_Ed25519).id + val didStr = DidService.create( + req.method, + didWebKeyId, + DidService.DidWebOptions( + domain = didDomain, + path = when (didDomain) { + didRegistryAuthority -> "api/did-registry/$didWebKeyId" + else -> req.didWebPath + } + ) + ) + val didDoc = DidService.load(didStr) + // !! Implicit USER CONTEXT is LOST after this statement !! + ContextManager.runWith(DidWebRegistryController.didRegistryContext) { + DidService.storeDid(didStr, didDoc.encodePretty()) + } + + ctx.result(didStr) + } + + DidMethod.key -> { + + ctx.result( + DidService.create( + req.method, + keyId ?: KeyService.getService().generate(KeyAlgorithm.EdDSA_Ed25519).id + ) + ) + } + + DidMethod.iota -> { + ctx.result( + DidService.create( + req.method, + keyId ?: KeyService.getService().generate(KeyAlgorithm.EdDSA_Ed25519).id + ) + ) + } + + else -> throw BadRequestResponse("DID method ${req.method} not yet supported") + } + } + + fun initCredentialPresentation(ctx: Context) { + val req = OIDC4VPService.parseOIDC4VPRequestUriFromHttpCtx(ctx) + val session = CredentialPresentationManager.initCredentialPresentation(req) + ctx.status(HttpCode.FOUND) + .header("Location", "${WalletConfig.config.walletUiUrl}/CredentialRequest/?sessionId=${session.id}") + } + + private fun startPresentationFromUri(ctx: Context) { + val req = ctx.bodyAsClass() + val oidc4VPReq = OIDC4VPService.parseOIDC4VPRequestUri(URI.create(req.oidcUri)) + val session = CredentialPresentationManager.initCredentialPresentation(oidc4VPReq) + ctx.result(session.id) + } + + fun initiateIssuance(ctx: Context) { + val issuanceInitiationReq = IssuanceInitiationRequest.fromQueryParams(ctx.queryParamMap()) + val sessionId = CredentialIssuanceManager.startIssuerInitiatedIssuance(issuanceInitiationReq) + ctx.status(HttpCode.FOUND) + .header("Location", "${WalletConfig.config.walletUiUrl}/InitiateIssuance/?sessionId=${sessionId}") + } + + fun continuePresentation(ctx: Context) { + val sessionId = ctx.queryParam("sessionId") ?: throw BadRequestResponse("sessionId not specified") + val did = ctx.queryParam("did") ?: throw BadRequestResponse("did not specified") + ctx.contentType(ContentType.APPLICATION_JSON).result( + KlaxonWithConverters().toJsonString( + CredentialPresentationManager.continueCredentialPresentationFor( + sessionId = sessionId, + did = did + ).sessionInfo + ) + ) + } + + fun fulfillPresentation(ctx: Context) { + val sessionId = ctx.queryParam("sessionId") ?: throw BadRequestResponse("sessionId not specified") + val selectedCredentials = ctx.body().let { KlaxonWithConverters().parseArray(it) } + ?: throw BadRequestResponse("No selected credentials given") + + ctx.json( + CredentialPresentationManager.fulfillPresentation(sessionId, selectedCredentials) + ) + } + + fun listIssuers(ctx: Context) { + ctx.json(WalletConfig.config.issuers.values) + } + + fun issuerMeta(ctx: Context) { + val metadata = ctx.queryParam("issuerId")?.let { + CredentialIssuanceManager.getIssuerWithMetadata(it) + }?.oidc_provider_metadata + + if (metadata != null) + ctx.json(metadata.toJSONObject()) + else + ctx.status(HttpCode.NOT_FOUND) + } + + fun startIssuance(ctx: Context) { + val issuance = ctx.bodyAsClass() + val location = CredentialIssuanceManager.startIssuance(issuance, JWTService.getUserInfo(ctx)!!) + ctx.result(location.toString()) + } + + fun startIssuanceForPresentation(ctx: Context) { + val issuance = ctx.bodyAsClass() + val location = CredentialIssuanceManager.startIssuanceForPresentation(issuance, JWTService.getUserInfo(ctx)!!) + ctx.result(location.toString()) + } + + fun startIssuerInitiatedIssuance(ctx: Context) { + val req = ctx.bodyAsClass() + val issuanceInitiationReq = + IssuanceInitiationRequest.fromQueryParams(URLUtils.parseParameters(URI.create(req.oidcUri).query)) + val sessionId = CredentialIssuanceManager.startIssuerInitiatedIssuance(issuanceInitiationReq) + ctx.result(sessionId) + } + + fun continueIssuerInitiatedIssuance(ctx: Context) { + val sessionId = ctx.queryParam("sessionId") ?: throw BadRequestResponse("Missing sessionId parameter") + val did = ctx.queryParam("did") ?: throw BadRequestResponse("Missing did parameter") + val userPin = ctx.queryParam("userPin") + try { + val session = CredentialIssuanceManager.continueIssuerInitiatedIssuance( + sessionId, + did, + JWTService.getUserInfo(ctx)!!, + userPin + ) + val location = + if (!session.isPreAuthorized) { // not pre-authorized, execute PAR request and provide user-agent address for authorization step + CredentialIssuanceManager.executeAuthorizationStep(session).toString() + } else { // pre-authorized issuance session, return UI address to success or error page + if (session.credentials != null) { + "/ReceiveCredential/?sessionId=${session.id}" + } else { + "/IssuanceError/" + } + } + ctx.result(location) + } catch (exc: Exception) { + println("Error: ${exc.message}") + exc.printStackTrace() + ctx.result("/IssuanceError/?reason=${URLEncoder.encode(exc.message, StandardCharsets.UTF_8)}") + exc.printStackTrace() + } + } + + fun finalizeIssuance(ctx: Context) { + val state = ctx.queryParam("state") + val code = ctx.queryParam("code") + if (state.isNullOrEmpty() || code.isNullOrEmpty()) { + ctx.status(HttpCode.BAD_REQUEST).result("No state or authorization code given") + return + } + val issuance = CredentialIssuanceManager.finalizeIssuance(state, code) + if (issuance.credentials != null) { + ctx.status(HttpCode.FOUND)//ReceiveCredential || /wallet + .header("Location", "${WalletConfig.config.walletUiUrl}/ReceiveCredential/?sessionId=${issuance.id}") + } else { + ctx.status(HttpCode.FOUND).header("Location", "${WalletConfig.config.walletUiUrl}/IssuanceError/") + } + } + + fun getIssuanceSessionInfo(ctx: Context) { + val sessionId = ctx.queryParam("sessionId") + val issuanceSession = sessionId?.let { CredentialIssuanceManager.getSession(it) } + if (issuanceSession == null) { + ctx.status(HttpCode.BAD_REQUEST).result("Invalid or expired session id given") + return + } + ctx.contentType(ContentType.JSON).result(KlaxonWithConverters().toJsonString(issuanceSession)) + } + + fun onboardGaiaX(ctx: Context) { + val credential = ctx.body().toVerifiableCredential() + val did = ctx.pathParam("did") + val compliance = issueSelfSignedCredential( + "LegalPerson", + did, + did, + credential.apply { proof = null }.toJsonObject() + ).run { + this.toVerifiableCredential().let { + Custodian.getService().storeCredential(it.id ?: UUID.randomUUID().toString(), it) + } + // TODO: this is just for demo purpose, generate credential from compliance service + issueSelfSignedCredential( + "ParticipantCredential", + did, + did, + ).toVerifiableCredential().run { + Custodian.getService().storeCredential(this.id ?: UUID.randomUUID().toString(), this) + this + }.encode() + } + ctx.result(compliance) + } + + private fun issueSelfSignedCredential( + template: String, + did: String, + verificationMethod: String, + data: Map? = null + ): String { + return Signatory.getService().issue( + template, ProofConfig( + subjectDid = did, + issuerDid = did, + issueDate = LocalDateTime.now().toInstant(ZoneOffset.UTC), + issuerVerificationMethod = verificationMethod, + proofPurpose = "assertionMethod" + ), dataProvider = data?.let { MergingDataProvider(data) } + ) + } + + private fun detectOIDCRequestType(context: Context) { + val uri = context.queryParam("uri")?.let { URI.create(it) } ?: throw BadRequestResponse("Missing parameter: uri") + if (kotlin.runCatching { + OIDC4VPService.getPresentationDefinition(OIDC4VPService.parseOIDC4VPRequestUri(uri)) + }.isSuccess) { + // OIDC4VP + context.result("presentation-request") + } else if (kotlin.runCatching { + IssuanceInitiationRequest.fromQueryParams(URLUtils.parseParameters(uri.query)) + }.isSuccess) { + // OIDC4VCI + context.result("credential-offer") + } else { + context.result("unknown") + } + } + +} diff --git a/src/main/resources/harmonize.json b/src/main/resources/harmonize.json new file mode 100644 index 0000000..45e1f51 --- /dev/null +++ b/src/main/resources/harmonize.json @@ -0,0 +1,7 @@ +{ + "GATEWAY_URL": "url", + "OAUTH_URL": "url", + "OAUTH_CLIENT_ID": "client-id", + "OAUTH_CLIENT_SECRET": "null", + "USER_ID": "user-id" +} \ No newline at end of file diff --git a/src/main/resources/simplelogger.properties b/src/main/resources/simplelogger.properties new file mode 100644 index 0000000..5f643b8 --- /dev/null +++ b/src/main/resources/simplelogger.properties @@ -0,0 +1,7 @@ +org.slf4j.simpleLogger.defaultLogLevel=info +org.slf4j.simpleLogger.log.id.walt=debug +org.slf4j.simpleLogger.log.com.zaxxer.hikari=warn + +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=yyyy-MM-dd HH:mm:ss:SSS Z +org.slf4j.simpleLogger.showShortLogName=false diff --git a/src/test/kotlin/id/walt/BaseApiTest.kt b/src/test/kotlin/id/walt/BaseApiTest.kt new file mode 100644 index 0000000..62b2e32 --- /dev/null +++ b/src/test/kotlin/id/walt/BaseApiTest.kt @@ -0,0 +1,104 @@ +package id.walt + +import id.walt.servicematrix.ServiceMatrix +import id.walt.servicematrix.ServiceRegistry +import id.walt.services.context.ContextManager +import id.walt.webwallet.backend.auth.JWTService +import id.walt.webwallet.backend.auth.UserInfo +import id.walt.webwallet.backend.context.WalletContextManager +import io.javalin.Javalin +import io.kotest.core.spec.style.AnnotationSpec +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging + + +private val log = KotlinLogging.logger {} + +abstract class BaseApiTest : AnnotationSpec() { + + val waltContext = WalletContextManager + val host = "localhost" + val port = 7777 + val url = "http://$host:$port" + var server: Javalin? = null + val client = HttpClient(CIO) { + install(ContentNegotiation) { + json() + } + expectSuccess = false + } + val email = "test@walt.id" + val did = "did:web:issuer.ssikit.org" + + @BeforeAll + fun init() { + ServiceMatrix("service-matrix.properties") + ServiceRegistry.registerService(waltContext) + } + + @BeforeClass + fun startServer() { + server = Javalin.create { config -> + config.apply { + enableDevLogging() + requestLogger { ctx, ms -> + log.debug { "${ctx.status()} ${ctx.ip()}: ${ctx.method()} ${ctx.fullUrl()}: ${ctx.body()} (Time: ${ms}ms)" } + } + accessManager(JWTService) + } + }.events { event -> + event.handlerAdded { + println("Registered handler: ${it.httpMethod.name} ${it.path}") + } + }.apply { + before(JWTService.jwtHandler) + before(waltContext.preRequestHandler) + after(waltContext.postRequestHandler) + routes { + loadRoutes() + } + }.start("0.0.0.0", port) + + } + + abstract fun loadRoutes() + + @AfterClass + fun teardown() { + server?.stop() + } + + fun authenticate(): UserInfo = runBlocking { + val userInfo = client.post("$url/api/auth/login") { + contentType(ContentType.Application.Json) + setBody( + mapOf( + "id" to email, + "email" to email, + "password" to "1234" + ) + ) + }.body() + return@runBlocking userInfo + } + + fun authenticateDid(): UserInfo = runBlocking { + val userInfo = client.post("$url/api/auth/login") { + contentType(ContentType.Application.Json) + setBody( + mapOf( + "id" to did, + "did" to did + ) + ) + }.body() + return@runBlocking userInfo + } +} diff --git a/src/test/kotlin/id/walt/onboarding/backend/OnboardingApiTest.kt b/src/test/kotlin/id/walt/onboarding/backend/OnboardingApiTest.kt new file mode 100644 index 0000000..f6a2d07 --- /dev/null +++ b/src/test/kotlin/id/walt/onboarding/backend/OnboardingApiTest.kt @@ -0,0 +1,63 @@ +package id.walt.onboarding.backend + +import id.walt.BaseApiTest +import id.walt.webwallet.backend.auth.AuthController +import io.javalin.apibuilder.ApiBuilder +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveLength +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking + +class OnboardingApiTests : BaseApiTest() { + + override fun loadRoutes() { + ApiBuilder.path("api") { + AuthController.routes + } + ApiBuilder.path("onboarding-api") { + OnboardingController.routes + } + } + + @Test() + fun testGenerateDomainVerificationCode() = runBlocking { + val userInfo = authenticateDid() + val code = client.post("$url/onboarding-api/domain/generateDomainVerificationCode"){ + header("Authorization", "Bearer ${userInfo.token}") + accept(ContentType("plain", "text")) + contentType(ContentType.Application.Json) + setBody(mapOf("domain" to "issuer.ssikit.org")) + }.bodyAsText() + println(code) + code shouldHaveLength 68 + code shouldBe DomainOwnershipService.generateWaltIdDomainVerificationCode("issuer.ssikit.org", did) + } + + @Test() + fun testCheckDomainVerificationCodeSuccess() = runBlocking { + val userInfo = authenticateDid() + val result = client.post("$url/onboarding-api/domain/checkDomainVerificationCode"){ + header("Authorization", "Bearer ${userInfo.token}") + accept(ContentType("plain", "text")) + contentType(ContentType.Application.Json) + setBody(mapOf("domain" to "issuer.ssikit.org")) + }.body() + result shouldBe true + } + + @Test() + fun testCheckDomainVerificationCodeFail() = runBlocking { + val userInfo = authenticateDid() + val result = client.post("$url/onboarding-api/domain/checkDomainVerificationCode"){ + header("Authorization", "Bearer ${userInfo.token}") + accept(ContentType("plain", "text")) + contentType(ContentType.Application.Json) + setBody(mapOf("domain" to "example.com")) + }.body() + result shouldBe false + } +} + diff --git a/src/test/kotlin/id/walt/webwallet/backend/wallet/SIOPv2Test.kt b/src/test/kotlin/id/walt/webwallet/backend/wallet/SIOPv2Test.kt new file mode 100644 index 0000000..0894534 --- /dev/null +++ b/src/test/kotlin/id/walt/webwallet/backend/wallet/SIOPv2Test.kt @@ -0,0 +1,303 @@ +package id.walt.webwallet.backend.wallet + +import com.nimbusds.oauth2.sdk.ResponseMode +import com.nimbusds.oauth2.sdk.util.URLUtils +import id.walt.BaseApiTest +import id.walt.auditor.PresentationDefinitionPolicy +import id.walt.common.KlaxonWithConverters +import id.walt.credentials.w3c.toVerifiableCredential +import id.walt.custodian.Custodian +import id.walt.issuer.backend.* +import id.walt.model.DidMethod +import id.walt.model.oidc.SIOPv2Response +import id.walt.multitenancy.TenantId +import id.walt.onboarding.backend.OnboardingController +import id.walt.services.context.ContextManager +import id.walt.services.did.DidService +import id.walt.services.hkvstore.InMemoryHKVStore +import id.walt.services.keystore.HKVKeyStoreService +import id.walt.services.oidc.OIDC4VPService +import id.walt.services.oidc.OidcSchemeFixer.unescapeOpenIdScheme +import id.walt.services.vcstore.HKVVcStoreService +import id.walt.signatory.ProofConfig +import id.walt.signatory.Signatory +import id.walt.verifier.backend.* +import id.walt.webwallet.backend.auth.AuthController +import id.walt.webwallet.backend.auth.UserInfo +import id.walt.webwallet.backend.config.WalletConfig +import id.walt.webwallet.backend.context.UserContext +import id.walt.webwallet.backend.context.UserContextLoader +import id.walt.webwallet.backend.context.WalletContextManager +import io.javalin.apibuilder.ApiBuilder +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.common.runBlocking +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.mockk.every +import io.mockk.mockkObject +import java.net.URI + +class SIOPv2Test : BaseApiTest() { + override fun loadRoutes() { + ApiBuilder.path("api") { + AuthController.routes + WalletController.routes + DidWebRegistryController.routes + } + ApiBuilder.path("verifier-api") { + VerifierController.routes + } + ApiBuilder.path("issuer-api") { + IssuerController.routes + } + ApiBuilder.path("onboarding-api") { + OnboardingController.routes + } + } + val clientNoFollow = HttpClient(CIO) { + install(ContentNegotiation) { + json() + } + expectSuccess = false + followRedirects = false + } + + + @BeforeClass + fun initSIOPTest() { + mockkObject(VerifierTenant, IssuerTenant, WalletConfig, UserContextLoader) + every { VerifierTenant.config } returns VerifierConfig( + verifierUiUrl = "$url", + verifierApiUrl = "$url/verifier-api/default", + wallets = mapOf( + "walt.id" to WalletConfiguration( + "walt.id", "$url", + "api/siop/initiatePresentation", "api/siop/initiateIssuance" , "walt.id web wallet" + ) + ) + ) + every { WalletConfig.config } returns WalletConfig( + walletUiUrl = "$url", + walletApiUrl = "$url/api" + ) + every { IssuerTenant.config } returns IssuerConfig( + issuerUiUrl = "$url", + issuerApiUrl = "$url/issuer-api/default" + ) + every { UserContextLoader.load(any()) } returns UserContext("testuser", + HKVKeyStoreService(), + HKVVcStoreService(), + InMemoryHKVStore() + ) + } + + @Test + fun test_OIDC4VP_vp_token_flow() = runBlocking { + // VERIFIER PORTAL + // verifier portal triggers presentation + val redirectToWalletResponse = clientNoFollow.get("${VerifierTenant.config.verifierApiUrl}/present/?walletId=walt.id&vcType=VerifiableId") {} + redirectToWalletResponse.status shouldBe HttpStatusCode.Found + val redirectToWalletLocation = redirectToWalletResponse.headers.get("Location")!! + val verificationReq = OIDC4VPService.parseOIDC4VPRequestUri(URI.create(redirectToWalletLocation)) + + // WALLET + // redirect to wallet api + val redirectToWalletUiResponse = clientNoFollow.get(redirectToWalletLocation) {} + redirectToWalletUiResponse.status shouldBe HttpStatusCode.Found + val redirectToWalletUiLocation = redirectToWalletUiResponse.headers.get("Location")!! + // parse redirection to wallet UI + val sessionId = URLUtils.parseParameters(URI.create(redirectToWalletUiLocation).query).get("sessionId")!!.first() + // simulate user auth + val userInfo = authenticate() + val did = ContextManager.runWith(WalletContextManager.getUserContext(userInfo)) { + DidService.listDids().first() + } + val vc = ContextManager.runWith(WalletContextManager.getUserContext(userInfo)) { + Signatory.getService().issue("VerifiableId", ProofConfig(did, did)).toVerifiableCredential().also { + Custodian.getService().storeCredential(it.id!!, it) + } + } + // wallet ui gets presentation session details + val presentationSessionInfo = client.get("$url/api/wallet/presentation/continue?sessionId=$sessionId&did=$did") { + header("Authorization", "Bearer ${userInfo.token}") + }.bodyAsText().let { + KlaxonWithConverters().parse(it) + } + presentationSessionInfo!!.id shouldBe sessionId + presentationSessionInfo.did shouldBe did + presentationSessionInfo.presentableCredentials shouldNotBe null + val presentableCredentials = presentationSessionInfo.presentableCredentials!!.filter { c -> c.credentialId == vc.id } + presentableCredentials.size shouldBe 1 + presentableCredentials.first().credentialId shouldBe vc.id + presentationSessionInfo.redirectUri shouldBe verificationReq.redirectionURI.toString() + + // wallet ui confirms presentation request + val presentationResponse = client.post("$url/api/wallet/presentation/fulfill?sessionId=$sessionId") { + header("Authorization", "Bearer ${userInfo.token}") + contentType(ContentType.Application.Json) + setBody(KlaxonWithConverters().parseArray?>(KlaxonWithConverters().toJsonString(presentableCredentials))) + }.bodyAsText().let { KlaxonWithConverters().parse(it) } + + presentationResponse!!.id_token shouldBe null + presentationResponse.vp_token shouldNotBe null + presentationResponse.presentation_submission shouldNotBe null + presentationResponse.state shouldBe verificationReq.state.value + + // VERIFIER + // receive presentation response + val redirectToVerifierUIResponse = clientNoFollow.submitForm(presentationSessionInfo.redirectUri, + Parameters.build { + append("vp_token", presentationResponse.vp_token) + append("presentation_submission", presentationResponse.presentation_submission) + presentationResponse.state?.let { append("state", it) } + } + ) + + redirectToVerifierUIResponse.status shouldBe HttpStatusCode.Found + val redirectToVerifierUILocation = redirectToVerifierUIResponse.headers["Location"] + val accessToken = URLUtils.parseParameters(URI.create(redirectToVerifierUILocation!!).query).get("access_token")!!.first() + + val verificationResult = client.get("${VerifierTenant.config.verifierApiUrl}/auth?access_token=$accessToken") {}.bodyAsText().let { KlaxonWithConverters().parse(it) } + verificationResult!!.isValid shouldBe true + verificationResult.subject shouldBe did + verificationResult.vps shouldHaveSize 1 + verificationResult.vps shouldHaveSize 1 + verificationResult.vps[0].vp.holder shouldBe did + verificationResult.vps[0].vp.verifiableCredential shouldNotBe null + verificationResult.vps[0].vp.verifiableCredential!! shouldHaveSize 1 + verificationResult.vps[0].vp.verifiableCredential!![0].id shouldBe vc.id + } + + @Test + fun testPreAuthzIssuanceFlow() { + val preAuthReq = ContextManager.runWith(IssuerManager.getIssuerContext(TenantId.DEFAULT_TENANT)) { + IssuerManager.newIssuanceInitiationRequest(Issuables( + credentials = listOf(IssuableCredential("VerifiableId", null)) + ), preAuthorized = true) + } + val userInfo = UserInfo("testuser") + val session = ContextManager.runWith(UserContextLoader.load(userInfo.id)) { + val subjectDid = DidService.create(DidMethod.key) + val sessionId = CredentialIssuanceManager.startIssuerInitiatedIssuance(preAuthReq) + CredentialIssuanceManager.continueIssuerInitiatedIssuance(sessionId, subjectDid, userInfo, null) + } + session.credentials shouldNotBe null + session.credentials!!.size shouldBe 1 + session.credentials!!.first().type shouldContain "VerifiableId" + } + + @Test + fun testPresentationDefinitionByReference() { + val req = ContextManager.runWith(VerifierManager.getService().getVerifierContext(TenantId.DEFAULT_TENANT)) { + // create req with pd by ref + VerifierManager.getService().newRequestByVcTypes( + "openid://", + setOf("VerifiableId"), + responseMode = ResponseMode("post"), + presentationDefinitionByReference = true + ) + } + val reqUri = req.toURI().unescapeOpenIdScheme() + + // try to parse OIDC4VP request + val parsedReq = shouldNotThrowAny { + println("Parsing OIDC4VPRequestUri: $reqUri") + OIDC4VPService.parseOIDC4VPRequestUri(reqUri) + } + + // try to get presentation definition from URL: + val pd = shouldNotThrowAny { + OIDC4VPService.getPresentationDefinition(parsedReq) + } + pd.input_descriptors.flatMap { id -> id.constraints?.fields ?: listOf() }.firstOrNull { fd -> + fd.path.contains("$.type") && fd.filter != null && fd.filter!!.containsKey("const") && fd.filter!!["const"] == "VerifiableId" + } shouldNotBe null + } + + @Test + fun test__legacy_OIDC4VP_vp_token_flow() = runBlocking { + // VERIFIER PORTAL + // verifier portal triggers presentation + val redirectToWalletResponse = clientNoFollow.get("${VerifierTenant.config.verifierApiUrl}/presentLegacy/?walletId=walt.id&vcType=VerifiableId") {} + redirectToWalletResponse.status shouldBe HttpStatusCode.Found + val redirectToWalletLocation = redirectToWalletResponse.headers.get("Location")!! + val verificationReq = OIDC4VPService.parseOIDC4VPRequestUri(URI.create(redirectToWalletLocation)) + + // WALLET + // redirect to wallet api + val redirectToWalletUiResponse = clientNoFollow.get(redirectToWalletLocation) {} + redirectToWalletUiResponse.status shouldBe HttpStatusCode.Found + val redirectToWalletUiLocation = redirectToWalletUiResponse.headers.get("Location")!! + // parse redirection to wallet UI + val sessionId = URLUtils.parseParameters(URI.create(redirectToWalletUiLocation).query).get("sessionId")!!.first() + // simulate user auth + val userInfo = authenticate() + val did = ContextManager.runWith(WalletContextManager.getUserContext(userInfo)) { + DidService.listDids().first() + } + val vc = ContextManager.runWith(WalletContextManager.getUserContext(userInfo)) { + Signatory.getService().issue("VerifiableId", ProofConfig(did, did)).toVerifiableCredential().also { + Custodian.getService().storeCredential(it.id!!, it) + } + } + // wallet ui gets presentation session details + val presentationSessionInfo = client.get("$url/api/wallet/presentation/continue?sessionId=$sessionId&did=$did") { + header("Authorization", "Bearer ${userInfo.token}") + }.bodyAsText().let { + KlaxonWithConverters().parse(it) + } + presentationSessionInfo!!.id shouldBe sessionId + presentationSessionInfo.did shouldBe did + presentationSessionInfo.presentableCredentials shouldNotBe null + val presentableCredentials = presentationSessionInfo.presentableCredentials!!.filter { c -> c.credentialId == vc.id } + presentableCredentials.size shouldBe 1 + presentableCredentials.first().credentialId shouldBe vc.id + presentationSessionInfo.redirectUri shouldBe verificationReq.redirectionURI.toString() + + // wallet ui confirms presentation request + val presentationResponse = client.post("$url/api/wallet/presentation/fulfill?sessionId=$sessionId") { + header("Authorization", "Bearer ${userInfo.token}") + contentType(ContentType.Application.Json) + setBody(KlaxonWithConverters().parseArray?>(KlaxonWithConverters().toJsonString(presentableCredentials))) + }.bodyAsText().let { KlaxonWithConverters().parse(it) } + + presentationResponse!!.id_token shouldNotBe null + presentationResponse.vp_token shouldNotBe null + presentationResponse.presentation_submission shouldNotBe null + presentationResponse.state shouldBe verificationReq.state.value + + // VERIFIER + // receive presentation response + val redirectToVerifierUIResponse = clientNoFollow.submitForm(presentationSessionInfo.redirectUri, + Parameters.build { + append("vp_token", presentationResponse.vp_token) + append("id_token", presentationResponse.id_token!!) + presentationResponse.state?.let { append("state", it) } + } + ) + + redirectToVerifierUIResponse.status shouldBe HttpStatusCode.Found + val redirectToVerifierUILocation = redirectToVerifierUIResponse.headers["Location"] + val accessToken = URLUtils.parseParameters(URI.create(redirectToVerifierUILocation!!).query).get("access_token")!!.first() + + val verificationResult = client.get("${VerifierTenant.config.verifierApiUrl}/auth?access_token=$accessToken") {}.bodyAsText().let { KlaxonWithConverters().parse(it) } + verificationResult!!.isValid shouldBe true + verificationResult.subject shouldBe did + verificationResult.vps shouldHaveSize 1 + verificationResult.vps shouldHaveSize 1 + verificationResult.vps[0].vp.holder shouldBe did + verificationResult.vps[0].vp.verifiableCredential shouldNotBe null + verificationResult.vps[0].vp.verifiableCredential!! shouldHaveSize 1 + verificationResult.vps[0].vp.verifiableCredential!![0].id shouldBe vc.id + } +} diff --git a/src/test/kotlin/id/walt/webwallet/backend/wallet/WalletApiTest.kt b/src/test/kotlin/id/walt/webwallet/backend/wallet/WalletApiTest.kt new file mode 100644 index 0000000..302afdd --- /dev/null +++ b/src/test/kotlin/id/walt/webwallet/backend/wallet/WalletApiTest.kt @@ -0,0 +1,122 @@ +package id.walt.webwallet.backend.wallet + +import id.walt.BaseApiTest +import id.walt.crypto.KeyAlgorithm +import id.walt.rest.custodian.ExportKeyRequest +import id.walt.services.key.KeyFormat +import id.walt.services.key.KeyService +import id.walt.services.keystore.KeyType +import id.walt.webwallet.backend.auth.AuthController +import io.javalin.apibuilder.ApiBuilder.path +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.data.blocking.forAll +import io.kotest.data.row +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldHaveMinLength +import io.kotest.matchers.string.shouldStartWith +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging + +private val log = KotlinLogging.logger {} + +class WalletApiTest : BaseApiTest() { + + override fun loadRoutes() { + path("api") { + AuthController.routes + WalletController.routes + } + } + + @Test() + fun testLogin() = runBlocking { + val userInfo = authenticate() + userInfo.token shouldHaveMinLength 100 + userInfo.id shouldBe email + userInfo.email shouldBe email + } + + @Test() + fun testDidsList() = runBlocking { + val userInfo = authenticate() + val did = client.get("$url/api/wallet/did/list") { + header("Authorization", "Bearer ${userInfo.token}") + contentType(ContentType.Application.Json) + }.bodyAsText() + println(did) + } + + // TODO: analyze potential walt-context issue @Test() + fun testDidWebCreate() = runBlocking { + val userInfo = authenticateDid() + val did = client.post("$url/api/wallet/did/create") { + header("Authorization", "Bearer ${userInfo.token}") + accept(ContentType("plain", "text")) + contentType(ContentType.Application.Json) + setBody(mapOf("method" to "web", "didWebDomain" to null)) + }.bodyAsText() + did shouldStartWith "did:web" + + println(did) + } + + @Test + fun testDeleteKey() { + val userInfo = authenticate() + val context = waltContext.getUserContext(userInfo) + val kid = waltContext.runWith(context) { KeyService.getService().generate(KeyAlgorithm.EdDSA_Ed25519) } + val response = runBlocking { + client.delete("$url/api/wallet/keys/delete/${kid.id}") { + header("Authorization", "Bearer ${userInfo.token}") + } + } + response.status shouldBe HttpStatusCode.OK + waltContext.runWith(context) { + shouldThrow { + KeyService.getService().load(kid.id) + } + } + } + + @Test + fun testExportKey() { + forAll( + row(KeyAlgorithm.EdDSA_Ed25519, KeyFormat.JWK, true), + row(KeyAlgorithm.ECDSA_Secp256k1, KeyFormat.JWK, true), + row(KeyAlgorithm.RSA, KeyFormat.JWK, true), + row(KeyAlgorithm.EdDSA_Ed25519, KeyFormat.PEM, true), + row(KeyAlgorithm.ECDSA_Secp256k1, KeyFormat.PEM, true), + row(KeyAlgorithm.RSA, KeyFormat.PEM, true), + row(KeyAlgorithm.EdDSA_Ed25519, KeyFormat.JWK, false), + row(KeyAlgorithm.ECDSA_Secp256k1, KeyFormat.JWK, false), + row(KeyAlgorithm.RSA, KeyFormat.JWK, false), + row(KeyAlgorithm.EdDSA_Ed25519, KeyFormat.PEM, false), + row(KeyAlgorithm.ECDSA_Secp256k1, KeyFormat.PEM, false), + row(KeyAlgorithm.RSA, KeyFormat.PEM, false), + ) { alg, format, private -> + val userInfo = authenticate() + val context = waltContext.getUserContext(userInfo) + val kid = waltContext.runWith(context) { KeyService.getService().generate(alg) } + val exportRequest = ExportKeyRequest(kid.id, format, private) + val keyStr = waltContext.runWith(context) { + KeyService.getService().export( + kid.id, + format, + if (private) KeyType.PRIVATE else KeyType.PUBLIC + ) + } + val response = runBlocking { + client.post("$url/api/wallet/keys/export") { + header("Authorization", "Bearer ${userInfo.token}") + contentType(ContentType.Application.Json) + setBody(exportRequest) + }.bodyAsText() + } + println(response) + response shouldBe keyStr + } + } +}