├── .dockerignore ├── .gitattributes ├── .github ├── actions │ └── chart_releaser │ │ ├── action.yaml │ │ ├── cr.sh │ │ └── cr.yaml ├── dependabot.yml ├── stale.yml └── workflows │ ├── chart_release.yaml │ ├── controller_unittests.yml │ ├── on_push_main.yaml │ └── publish.yml ├── .gitignore ├── .tgitconfig ├── COMPLIANCE.yaml ├── LICENSE ├── README.md ├── charts ├── README.md └── vc-authn-oidc │ ├── .helmignore │ ├── Chart.lock │ ├── Chart.yaml │ ├── README.md │ ├── charts │ ├── common-2.27.0.tgz │ ├── mongodb-16.4.1.tgz │ └── postgresql-15.5.38.tgz │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── agent │ │ ├── configmap.yaml │ │ ├── deployment.yaml │ │ ├── hpa.yaml │ │ ├── secrets.yaml │ │ ├── service.yaml │ │ ├── serviceaccount.yaml │ │ └── tails_pvc.yaml │ ├── configmap.yaml │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── jwt-secret.yaml │ ├── networkpolicy-agent-ingress.yaml │ ├── networkpolicy-agent.yaml │ ├── networkpolicy-db.yaml │ ├── networkpolicy-ingress.yaml │ ├── secrets.yaml │ ├── service.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── demo └── vue │ ├── .codeclimate.yml │ ├── .dockerignore │ ├── .editorconfig │ ├── .gitattributes │ ├── .gitignore │ ├── Dockerfile │ ├── LICENSE │ ├── README.md │ ├── app │ ├── .editorconfig │ ├── .eslintignore │ ├── .eslintrc.js │ ├── .prettierrc │ ├── README.md │ ├── app.js │ ├── bin │ │ └── www │ ├── config │ │ ├── custom-environment-variables.json │ │ ├── default.json │ │ ├── production.json │ │ └── test.json │ ├── frontend │ │ ├── .browserslistrc │ │ ├── .env.development │ │ ├── .env.test │ │ ├── .eslintignore │ │ ├── .eslintrc.js │ │ ├── .prettierrc │ │ ├── LICENSE │ │ ├── README.md │ │ ├── babel.config.js │ │ ├── jest.config.js │ │ ├── jsconfig.json │ │ ├── lcov-fix.js │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── public │ │ │ ├── favicon.ico │ │ │ └── index.html │ │ ├── src │ │ │ ├── App.vue │ │ │ ├── assets │ │ │ │ ├── images │ │ │ │ │ └── OpenWallet_Foundation_Logo_Color.svg │ │ │ │ └── scss │ │ │ │ │ └── style.scss │ │ │ ├── components │ │ │ │ ├── HelloCall.vue │ │ │ │ ├── HelloWorld.vue │ │ │ │ ├── base │ │ │ │ │ ├── BaseAuthButton.vue │ │ │ │ │ ├── BaseDialog.vue │ │ │ │ │ └── BaseSecure.vue │ │ │ │ └── owf │ │ │ │ │ ├── OWFFooter.vue │ │ │ │ │ ├── OWFHeader.vue │ │ │ │ │ └── OWFNavBar.vue │ │ │ ├── main.js │ │ │ ├── plugins │ │ │ │ ├── keycloak.js │ │ │ │ └── vuetify.js │ │ │ ├── router │ │ │ │ └── index.js │ │ │ ├── services │ │ │ │ ├── helloService.js │ │ │ │ ├── index.js │ │ │ │ └── interceptors.js │ │ │ ├── store │ │ │ │ ├── index.js │ │ │ │ └── modules │ │ │ │ │ └── auth.js │ │ │ ├── utils │ │ │ │ └── constants.js │ │ │ └── views │ │ │ │ ├── Home.vue │ │ │ │ ├── NotFound.vue │ │ │ │ └── Secure.vue │ │ └── vue.config.js │ ├── jest.config.js │ ├── lcov-fix.js │ ├── nodemon.json │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── clientConnection.js │ │ │ ├── errorToProblem.js │ │ │ ├── hello.js │ │ │ ├── keycloak.js │ │ │ └── log.js │ │ ├── docs │ │ │ ├── docs.js │ │ │ └── v1.api-spec.yaml │ │ └── routes │ │ │ ├── v1.js │ │ │ └── v1 │ │ │ └── hello.js │ └── tests │ │ ├── common │ │ └── helper.js │ │ └── unit │ │ ├── components │ │ ├── errorToProblem.spec.js │ │ ├── hello.spec.js │ │ └── log.spec.js │ │ └── routes │ │ ├── v1.spec.js │ │ └── v1 │ │ └── hello.spec.js │ ├── docker-compose.yaml │ └── vetur.config.js ├── docker ├── agent │ └── config │ │ └── ledgers.yaml ├── docker-compose-ngrok.yaml ├── docker-compose.yaml ├── keycloak │ └── config │ │ └── keycloak_import.json ├── manage ├── mongo │ └── mongo-init.js ├── ngrok.yml └── oidc-controller │ ├── Dockerfile │ └── config │ ├── sessiontimeout.json │ ├── user_variable_substitution.py │ └── user_variable_substitution_example.py ├── docs ├── BestPractices.md ├── ConfigurationGuide.md ├── MigrationGuide.md ├── README.md ├── img │ ├── 01-new-idp.png │ ├── 02-settings-1.png │ ├── 02-settings-2.png │ ├── 03-mappers.png │ ├── ecosystem.svg │ └── vc-authn-oidc-flow.png └── vc-authn-oidc-flow.puml ├── html-templates ├── assets │ ├── css │ │ ├── bootstrap.533.min.css │ │ └── custom.css │ ├── img │ │ ├── circle-check.svg │ │ ├── circle-x.svg │ │ ├── dashed-border.svg │ │ ├── digital-wallet.svg │ │ ├── expired.svg │ │ ├── hand-qrcode.svg │ │ ├── header-logo.svg │ │ ├── new-digital-wallet.svg │ │ ├── refresh.svg │ │ └── spinner.svg │ └── js │ │ ├── socket.io.475.min.js │ │ ├── ua-parser.min.js │ │ └── vue.global.prod.3512.js ├── ver_config_explorer.html ├── verified_credentials.html └── wallet_howto.html ├── oidc-controller ├── .coveragerc ├── api │ ├── authSessions │ │ ├── crud.py │ │ └── models.py │ ├── clientConfigurations │ │ ├── crud.py │ │ ├── examples.py │ │ ├── models.py │ │ ├── router.py │ │ └── tests │ │ │ └── test_cc_crud.py │ ├── core │ │ ├── __init__.py │ │ ├── acapy │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ ├── config.py │ │ │ ├── models.py │ │ │ ├── out_of_band.py │ │ │ ├── present_proof_attachment.py │ │ │ ├── present_proof_presentation.py │ │ │ ├── service_decorator.py │ │ │ └── tests │ │ │ │ ├── __mocks__.py │ │ │ │ ├── test_client.py │ │ │ │ └── test_config.py │ │ ├── auth.py │ │ ├── config.py │ │ ├── http_exception_util.py │ │ ├── logger_util.py │ │ ├── models.py │ │ ├── oidc │ │ │ ├── issue_token_service.py │ │ │ ├── provider.py │ │ │ └── tests │ │ │ │ ├── __mocks__.py │ │ │ │ └── test_issue_token_service.py │ │ └── tests │ │ │ └── test_core_config.py │ ├── db │ │ ├── __init__.py │ │ ├── collections.py │ │ └── session.py │ ├── logconf.json │ ├── main.py │ ├── routers │ │ ├── __init__.py │ │ ├── acapy_handler.py │ │ ├── oidc.py │ │ ├── presentation_request.py │ │ ├── socketio.py │ │ └── well_known_oid_config.py │ └── verificationConfigs │ │ ├── crud.py │ │ ├── examples.py │ │ ├── helpers.py │ │ ├── models.py │ │ ├── router.py │ │ ├── tests │ │ ├── test_helpers.py │ │ ├── test_variable_substitutions.py │ │ └── test_vc_crud.py │ │ └── variableSubstitutions.py ├── conftest.py ├── docker-entrypoint.sh ├── pytest.ini ├── setup.py └── tox.ini ├── poetry.lock └── pyproject.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | **/node_modules 4 | .dockerignore 5 | .env 6 | .vs 7 | .vscode 8 | docker-compose*.yml 9 | **/bin 10 | **/obj 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set the default behavior, in case people don't have core.autocrlf set. 2 | * text=auto 3 | 4 | # Declare files that will always have LF line endings on checkout. 5 | *.sh text eol=lf 6 | *.md text eol=lf 7 | *.json text eol=lf 8 | *.conf text eol=lf 9 | -------------------------------------------------------------------------------- /.github/actions/chart_releaser/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Helm Chart Releaser" 2 | description: "Host a Helm charts repo on GitHub Pages" 3 | author: "The Helm authors" 4 | branding: 5 | color: blue 6 | icon: anchor 7 | inputs: 8 | version: 9 | description: "The chart-releaser version to use (default: v1.6.0)" 10 | required: false 11 | default: v1.6.0 12 | config: 13 | description: "The relative path to the chart-releaser config file" 14 | required: false 15 | charts_dir: 16 | description: The charts directory 17 | required: false 18 | default: charts 19 | install_dir: 20 | description: "Where to install the cr tool" 21 | required: false 22 | install_only: 23 | description: "Just install cr tool" 24 | required: false 25 | skip_packaging: 26 | description: "Skip the packaging option (do your custom packaging before running this action)" 27 | required: false 28 | skip_existing: 29 | description: "Skip package upload if release exists" 30 | required: false 31 | mark_as_latest: 32 | description: Mark the created GitHub release as 'latest' 33 | required: false 34 | default: true 35 | outputs: 36 | changed_charts: 37 | description: "A comma-separated list of charts that were released on this run. Will be an empty string if no updates were detected, will be unset if `--skip_packaging` is used: in the latter case your custom packaging step is responsible for setting its own outputs if you need them." 38 | value: ${{ steps.release.outputs.changed_charts }} 39 | chart_version: 40 | description: "The version of the most recently generated charts; will be set even if no charts have been updated since the last run." 41 | value: ${{ steps.release.outputs.chart_version }} 42 | 43 | runs: 44 | using: composite 45 | steps: 46 | - id: release 47 | run: | 48 | owner=$(cut -d '/' -f 1 <<< "$GITHUB_REPOSITORY") 49 | repo=$(cut -d '/' -f 2 <<< "$GITHUB_REPOSITORY") 50 | 51 | args=(--owner "$owner" --repo "$repo") 52 | args+=(--charts-dir "${{ inputs.charts_dir }}") 53 | 54 | if [[ -n "${{ inputs.version }}" ]]; then 55 | args+=(--version "${{ inputs.version }}") 56 | fi 57 | 58 | if [[ -n "${{ inputs.config }}" ]]; then 59 | args+=(--config "${{ inputs.config }}") 60 | fi 61 | 62 | if [[ -z "${{ inputs.install_dir }}" ]]; then 63 | install="$RUNNER_TOOL_CACHE/cr/${{ inputs.version }}/$(uname -m)" 64 | echo "$install" >> "$GITHUB_PATH" 65 | args+=(--install-dir "$install") 66 | else 67 | echo ${{ inputs.install_dir }} >> "$GITHUB_PATH" 68 | args+=(--install-dir "${{ inputs.install_dir }}") 69 | fi 70 | 71 | if [[ -n "${{ inputs.install_only }}" ]]; then 72 | args+=(--install-only "${{ inputs.install_only }}") 73 | fi 74 | 75 | if [[ -n "${{ inputs.skip_packaging }}" ]]; then 76 | args+=(--skip-packaging "${{ inputs.skip_packaging }}") 77 | fi 78 | 79 | if [[ -n "${{ inputs.skip_existing }}" ]]; then 80 | args+=(--skip-existing "${{ inputs.skip_existing }}") 81 | fi 82 | 83 | if [[ -n "${{ inputs.mark_as_latest }}" ]]; then 84 | args+=(--mark-as-latest "${{ inputs.mark_as_latest }}") 85 | fi 86 | 87 | "$GITHUB_ACTION_PATH/cr.sh" "${args[@]}" 88 | 89 | if [[ -f changed_charts.txt ]]; then 90 | cat changed_charts.txt >> "$GITHUB_OUTPUT" 91 | fi 92 | if [[ -f chart_version.txt ]]; then 93 | cat chart_version.txt >> "$GITHUB_OUTPUT" 94 | fi 95 | rm -f changed_charts.txt chart_version.txt 96 | shell: bash 97 | -------------------------------------------------------------------------------- /.github/actions/chart_releaser/cr.yaml: -------------------------------------------------------------------------------- 1 | owner: openwallet-foundation 2 | git-repo: acapy-vc-authn-oidc 3 | git-base-url: https://api.github.com/ 4 | git-upload-url: https://uploads.github.com/ 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # For details on how this file works refer to: 2 | # - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | version: 2 4 | updates: 5 | # Maintain dependencies for GitHub Actions 6 | # - Check for updates once a week 7 | # - Group all updates into a single PR 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | all-actions: 14 | patterns: [ "*" ] 15 | 16 | # Maintain dependencies for Python packages 17 | - package-ecosystem: "pip" 18 | directory: "/oidc-controller" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | time: "04:00" 23 | timezone: "Canada/Pacific" 24 | ignore: 25 | - dependency-name: "*" 26 | update-types: ["version-update:semver-major"] 27 | 28 | # Maintain dependencies for Vue 29 | - package-ecosystem: "npm" 30 | directory: "/demo/vue/app" 31 | schedule: 32 | interval: "weekly" 33 | day: "monday" 34 | time: "04:00" 35 | timezone: "Canada/Pacific" 36 | ignore: 37 | - dependency-name: "*" 38 | update-types: ["version-update:semver-major", "version-update:semver-patch"] 39 | 40 | # Maintain dependencies for Vue 41 | - package-ecosystem: "npm" 42 | directory: "/demo/vue/app/frontend" 43 | schedule: 44 | interval: "weekly" 45 | day: "monday" 46 | time: "04:00" 47 | timezone: "Canada/Pacific" 48 | ignore: 49 | - dependency-name: "*" 50 | update-types: ["version-update:semver-major", "version-update:semver-patch"] 51 | 52 | # Maintain dependencies for docker 53 | - package-ecosystem: "docker" 54 | directory: "/docker/oidc-controller" 55 | schedule: 56 | interval: "weekly" 57 | day: "monday" 58 | time: "04:00" 59 | timezone: "Canada/Pacific" 60 | 61 | # Maintain dependencies for docker 62 | - package-ecosystem: "docker" 63 | directory: "/demo/vue" 64 | schedule: 65 | interval: "weekly" 66 | day: "monday" 67 | time: "04:00" 68 | timezone: "Canada/Pacific" -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for https://github.com/actions/stale 2 | 3 | name: 'Close stale issues and PRs' 4 | on: 5 | schedule: 6 | - cron: '30 1 * * *' 7 | 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v8 13 | with: 14 | stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' 15 | clos-issue-message: 'This issue has been automatically closed because it has become stale.' 16 | stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' 17 | close-pr-message: 'This pull request has been automatically closed because it has become stale.' 18 | stale-issue-label: 'stale' 19 | exempt-issue-labels: 'pinned,security,dependencies,epic' 20 | stale-pr-label: 'stale' 21 | exempt-pr-labels: 'awaiting-approval,work-in-progress,dependencies' 22 | -------------------------------------------------------------------------------- /.github/workflows/chart_release.yaml: -------------------------------------------------------------------------------- 1 | name: Helm Chart Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | chart-release: 10 | name: Create and Publish Chart Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - name: Configure Git 17 | run: | 18 | git config user.name "$GITHUB_ACTOR" 19 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 20 | - name: Install Helm 21 | uses: azure/setup-helm@v4 22 | - name: Add bitnami repository 23 | run: helm repo add bitnami https://charts.bitnami.com/bitnami 24 | - name: Run chart-releaser 25 | uses: ./.github/actions/chart_releaser 26 | with: 27 | config: .github/actions/chart_releaser/cr.yaml 28 | env: 29 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 30 | -------------------------------------------------------------------------------- /.github/workflows/controller_unittests.yml: -------------------------------------------------------------------------------- 1 | name: Controller Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | pull_request: 8 | branches: 9 | - "main" 10 | 11 | jobs: 12 | build: 13 | name: Build, Lint, Unit Test 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.12"] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | working-directory: ./oidc-controller 26 | run: | 27 | pip3 install --no-cache-dir poetry==2.0.0 28 | poetry install --no-root 29 | - name: Lint with black 30 | working-directory: ./oidc-controller 31 | run: | 32 | poetry run black --check . 33 | - name: Test with pytest 34 | working-directory: ./oidc-controller 35 | run: | 36 | poetry run pytest --log-cli-level=INFO --cov --cov-report lcov 37 | - name: Coveralls Parallel 38 | uses: coverallsapp/github-action@v2 39 | with: 40 | flag-name: python-${{ matrix.python-version }} 41 | parallel: true 42 | code-coverage: 43 | name: Generate Code Coverage 44 | needs: build 45 | if: ${{ always() }} 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Coveralls Finished 49 | uses: coverallsapp/github-action@v2 50 | with: 51 | parallel-finished: true 52 | carryforward: "python-3.11" 53 | -------------------------------------------------------------------------------- /.github/workflows/on_push_main.yaml: -------------------------------------------------------------------------------- 1 | name: Build latest development image 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "docker/oidc-controller/**" 9 | - "oidc-controller/**" 10 | - "html-templates/**" 11 | - "pyproject.toml" 12 | - "poetry.lock" 13 | jobs: 14 | build: 15 | name: "Build ACAPy VC-AuthN" 16 | if: github.repository_owner == 'openwallet-foundation' 17 | uses: ./.github/workflows/publish.yml 18 | with: 19 | ref: "main" 20 | platforms: "linux/amd64,linux/arm64" 21 | -------------------------------------------------------------------------------- /.tgitconfig: -------------------------------------------------------------------------------- 1 | [tgit] 2 | warnnosignedoffby = true -------------------------------------------------------------------------------- /COMPLIANCE.yaml: -------------------------------------------------------------------------------- 1 | name: compliance 2 | description: | 3 | This document is used to track a projects PIA and STRA 4 | compliance. 5 | spec: 6 | - name: PIA 7 | status: not-required 8 | last-updated: '2020-07-21T22:41:43.738Z' 9 | - name: STRA 10 | status: not-required 11 | last-updated: '2020-07-21T22:41:43.738Z' 12 | -------------------------------------------------------------------------------- /charts/README.md: -------------------------------------------------------------------------------- 1 | # VC-AuthN OIDC 2 | 3 | Helm charts to deploy VC-AuthN OIDC in k8s 4 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: mongodb 3 | repository: https://charts.bitnami.com/bitnami 4 | version: 16.4.1 5 | - name: postgresql 6 | repository: https://charts.bitnami.com/bitnami/ 7 | version: 15.5.38 8 | - name: common 9 | repository: https://charts.bitnami.com/bitnami 10 | version: 2.27.0 11 | digest: sha256:0d13ea3f0da59944a317a090aff1f2551a4f6e7cda7df1dff802cb1986492060 12 | generated: "2025-03-14T16:52:38.862945-07:00" 13 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: vc-authn-oidc 3 | description: A Helm chart to deploy ACAPy VC-AuthN on OpenShift 4 | type: application 5 | 6 | # This is the chart version. This version number should be incremented each time you make changes 7 | # to the chart and its templates, including the app version. 8 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 9 | version: 0.3.2 10 | 11 | # This is the version number of the application being deployed. This version number should be 12 | # incremented each time you make changes to the application. Versions are not expected to 13 | # follow Semantic Versioning. They should reflect the version the application is using. 14 | # It is recommended to use it with quotes. 15 | appVersion: "2.2.3" 16 | 17 | # Charts the vc-authn-oidc service depends on 18 | dependencies: 19 | - name: mongodb 20 | version: 16.4.1 21 | repository: "https://charts.bitnami.com/bitnami" 22 | - name: postgresql 23 | version: 15.5.38 24 | repository: https://charts.bitnami.com/bitnami/ 25 | condition: postgresql.enabled 26 | - name: common 27 | repository: "https://charts.bitnami.com/bitnami" 28 | tags: 29 | - bitnami-common 30 | version: 2.x.x 31 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/charts/common-2.27.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/charts/vc-authn-oidc/charts/common-2.27.0.tgz -------------------------------------------------------------------------------- /charts/vc-authn-oidc/charts/mongodb-16.4.1.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/charts/vc-authn-oidc/charts/mongodb-16.4.1.tgz -------------------------------------------------------------------------------- /charts/vc-authn-oidc/charts/postgresql-15.5.38.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/charts/vc-authn-oidc/charts/postgresql-15.5.38.tgz -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | 4 | https://{{ template "vc-authn-oidc.host" . }} 5 | 6 | Webhooks URL: 7 | https://{{ template "vc-authn-oidc.host" . }}/webhooks 8 | 9 | {{- else if contains "NodePort" .Values.service.type }} 10 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "global.fullname" . }}) 11 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 12 | echo http://$NODE_IP:$NODE_PORT 13 | {{- else if contains "LoadBalancer" .Values.service.type }} 14 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 15 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "global.fullname" . }}' 16 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "global.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 17 | echo http://$SERVICE_IP:{{ .Values.service.port }} 18 | {{- else if contains "ClusterIP" .Values.service.type }} 19 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "global.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 20 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 21 | echo "Visit http://127.0.0.1:8080 to use your application" 22 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 23 | {{- end }} 24 | 25 | 2. Run the following command to set the API key for the Webhooks: 26 | export CONTROLLER_API_KEY=$(kubectl get secret --namespace {{ .Release.Namespace }} {{ include "vc-authn-oidc.apiSecretName" . }} -o jsonpath="{.data.controllerApiKey}" | base64 --decode; echo) 27 | echo $CONTROLLER_API_KEY 28 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/agent/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "acapy.fullname" . }}-config 5 | labels: 6 | {{- include "acapy.labels" . | nindent 4 }} 7 | data: 8 | argfile.yml: | 9 | {{- if (index .Values "acapy" "argfile.yml") }} 10 | {{- include "common.tplvalues.render" ( dict "value" (index .Values "acapy" "argfile.yml") "context" $) | nindent 4 }} 11 | {{- end }} 12 | ledgers.yml: | 13 | {{- if index .Values "acapy" "ledgers.yml" }} 14 | {{- include "common.tplvalues.render" ( dict "value" (index .Values "acapy" "ledgers.yml") "context" $) | nindent 4 }} 15 | {{- end }} 16 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/agent/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.acapy.autoscaling.enabled }} 2 | {{- $acapyFullName := include "acapy.fullname" . -}} 3 | apiVersion: {{ include "common.capabilities.hpa.apiVersion" ( dict "context" $ ) }} 4 | kind: HorizontalPodAutoscaler 5 | metadata: 6 | name: {{ $acapyFullName }} 7 | labels: 8 | {{- include "acapy.labels" . | nindent 4 }} 9 | spec: 10 | scaleTargetRef: 11 | apiVersion: apps/v1 12 | kind: Deployment 13 | name: {{ $acapyFullName }} 14 | minReplicas: {{ .Values.acapy.autoscaling.minReplicas }} 15 | maxReplicas: {{ .Values.acapy.autoscaling.maxReplicas }} 16 | metrics: 17 | {{- if .Values.acapy.autoscaling.targetCPUUtilizationPercentage }} 18 | - type: Resource 19 | resource: 20 | name: cpu 21 | {{- if semverCompare "<1.23-0" (include "common.capabilities.kubeVersion" .) }} 22 | targetAverageUtilization: {{ .Values.acapy.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- else }} 24 | target: 25 | type: Utilization 26 | averageUtilization: {{ .Values.acapy.autoscaling.targetCPUUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | {{- if .Values.acapy.autoscaling.targetMemoryUtilizationPercentage }} 30 | - type: Resource 31 | resource: 32 | name: memory 33 | {{- if semverCompare "<1.23-0" (include "common.capabilities.kubeVersion" .) }} 34 | targetAverageUtilization: {{ .Values.acapy.autoscaling.targetMemoryUtilizationPercentage }} 35 | {{- else }} 36 | target: 37 | type: Utilization 38 | averageUtilization: {{ .Values.acapy.autoscaling.targetMemoryUtilizationPercentage }} 39 | {{- end }} 40 | {{- end }} 41 | behavior: 42 | scaleDown: 43 | stabilizationWindowSeconds: {{ .Values.acapy.autoscaling.stabilizationWindowSeconds }} 44 | {{- end }} 45 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/agent/secrets.yaml: -------------------------------------------------------------------------------- 1 | {{- if (include "acapy.createSecret" .) }} 2 | {{ $secretName := include "acapy.secretName" . }} 3 | {{ $adminApiKey := include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" $secretName "Key" "adminApiKey" "Length" 32) }} 4 | {{ $walletKey := include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" $secretName "Key" "walletKey" "Length" 32) }} 5 | apiVersion: v1 6 | kind: Secret 7 | metadata: 8 | annotations: 9 | "helm.sh/resource-policy": keep 10 | name: {{ template "acapy.secretName" . }} 11 | labels: 12 | {{- include "acapy.labels" . | nindent 4 }} 13 | namespace: {{ .Release.Namespace }} 14 | type: Opaque 15 | data: 16 | {{- if not (index .Values "acapy" "argfile.yml" "admin-insecure-mode") }} 17 | adminApiKey: {{ $adminApiKey }} 18 | {{- end }} 19 | walletKey: {{ $walletKey }} 20 | {{- end }} 21 | --- 22 | {{- if (include "acapy.seed.createSecret" .) }} 23 | apiVersion: v1 24 | kind: Secret 25 | metadata: 26 | annotations: 27 | "helm.sh/resource-policy": keep 28 | name: {{ template "acapy.fullname" . }} 29 | labels: 30 | {{- include "acapy.selectorLabels" . | nindent 4 }} 31 | namespace: {{ .Release.Namespace }} 32 | type: Opaque 33 | data: 34 | seed: {{ include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" (include "acapy.fullname" .) "Key" "seed" "Length" 32) }} 35 | {{- end }} 36 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/agent/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "acapy.fullname" . }} 5 | labels: 6 | {{- include "acapy.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.acapy.service.type }} 9 | ports: 10 | - port: {{ .Values.acapy.service.httpPort }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | - port: {{ .Values.acapy.service.adminPort }} 15 | targetPort: admin 16 | protocol: TCP 17 | name: admin 18 | selector: 19 | {{- include "acapy.selectorLabels" . | nindent 4 }} 20 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/agent/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.acapy.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "acapy.serviceAccountName" . }} 6 | labels: 7 | {{- include "acapy.labels" . | nindent 4 }} 8 | {{- with .Values.acapy.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/agent/tails_pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.acapy.persistence.existingClaim }} 2 | apiVersion: v1 3 | kind: PersistentVolumeClaim 4 | metadata: 5 | name: {{ include "acapy.tails.pvc.name" . }} 6 | labels: 7 | {{- include "acapy.labels" . | nindent 4 }} 8 | annotations: 9 | "helm.sh/resource-policy": keep 10 | spec: 11 | accessModes: 12 | {{- range .Values.acapy.persistence.accessModes }} 13 | - {{ . | quote }} 14 | {{- end }} 15 | resources: 16 | requests: 17 | storage: {{ .Values.acapy.persistence.size | quote }} 18 | {{- include "common.storage.class" (dict "persistence" .Values.acapy.persistence "global" .Values.global) | nindent 2 }} 19 | {{- end}} 20 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: {{ include "global.fullname" . }}-controller-config 5 | labels: {{- include "vc-authn-oidc.labels" . | nindent 4 }} 6 | data: 7 | sessiontimeout.json: | 8 | {{ .Values.controller.sessionTimeout.config | toJson }} 9 | user_variable_substitution.py: | 10 | {{ .Values.controller.userVariableSubsitution | nindent 4 }} 11 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: {{ include "common.capabilities.hpa.apiVersion" ( dict "context" $ ) }} 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "global.fullname" . }} 6 | labels: 7 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "global.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | {{- if semverCompare "<1.23-0" (include "common.capabilities.kubeVersion" .) }} 21 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 22 | {{- else }} 23 | target: 24 | type: Utilization 25 | averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 26 | {{- end }} 27 | {{- end }} 28 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 29 | - type: Resource 30 | resource: 31 | name: memory 32 | {{- if semverCompare "<1.23-0" (include "common.capabilities.kubeVersion" .) }} 33 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 34 | {{- else }} 35 | target: 36 | type: Utilization 37 | averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 38 | {{- end }} 39 | {{- end }} 40 | behavior: 41 | scaleDown: 42 | stabilizationWindowSeconds: {{ .Values.autoscaling.stabilizationWindowSeconds }} 43 | {{- end }} 44 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "global.fullname" . -}} 3 | {{- $acapyFullName := include "acapy.fullname" . -}} 4 | {{- $httpPort := .Values.acapy.service.httpPort -}} 5 | {{- $adminPort := .Values.acapy.service.adminPort -}} 6 | {{- $svcPort := .Values.service.port -}} 7 | {{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} 8 | {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} 9 | {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} 10 | {{- end }} 11 | {{- end }} 12 | {{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 13 | apiVersion: networking.k8s.io/v1 14 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 15 | apiVersion: networking.k8s.io/v1beta1 16 | {{- else -}} 17 | apiVersion: extensions/v1beta1 18 | {{- end }} 19 | kind: Ingress 20 | metadata: 21 | name: {{ $fullName }} 22 | labels: 23 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 24 | {{- with .Values.ingress.annotations }} 25 | annotations: 26 | {{- toYaml . | nindent 4 }} 27 | {{- end }} 28 | spec: 29 | {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} 30 | ingressClassName: {{ .Values.ingress.className }} 31 | {{- end }} 32 | {{- if .Values.ingress.tls }} 33 | tls: 34 | {{- range .Values.ingress.tls }} 35 | - hosts: 36 | {{- range .hosts }} 37 | - {{ . | quote }} 38 | {{- end }} 39 | secretName: {{ .secretName }} 40 | {{- end }} 41 | {{- end }} 42 | rules: 43 | - host: {{ include "vc-authn-oidc.host" . | quote }} 44 | http: 45 | paths: 46 | - path: / 47 | {{- if (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 48 | pathType: ImplementationSpecific 49 | {{- end }} 50 | backend: 51 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 52 | service: 53 | name: {{ $fullName }} 54 | port: 55 | number: {{ $svcPort }} 56 | {{- else }} 57 | serviceName: {{ $fullName }} 58 | servicePort: {{ $svcPort }} 59 | {{- end }} 60 | {{- if .Values.acapy.enabled }} 61 | - host: {{ include "acapy.host" . | quote }} 62 | http: 63 | paths: 64 | - path: / 65 | {{- if (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 66 | pathType: ImplementationSpecific 67 | {{- end }} 68 | backend: 69 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 70 | service: 71 | name: {{ $acapyFullName }} 72 | port: 73 | number: {{ $httpPort }} 74 | {{- else }} 75 | serviceName: {{ $acapyFullName }} 76 | servicePort: {{ $httpPort }} 77 | {{- end }} 78 | - host: {{ include "acapy.admin.host" . | quote }} 79 | http: 80 | paths: 81 | - path: / 82 | {{- if (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} 83 | pathType: ImplementationSpecific 84 | {{- end }} 85 | backend: 86 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 87 | service: 88 | name: {{ $acapyFullName }} 89 | port: 90 | number: {{ $adminPort }} 91 | {{- else }} 92 | serviceName: {{ $acapyFullName }} 93 | servicePort: {{ $adminPort }} 94 | {{- end }} 95 | {{- end }} 96 | {{- end }} 97 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/jwt-secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if (include "vc-authn-oidc.token.createSecret" .) }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ include "vc-authn-oidc.token.secretName" . }} 6 | labels: 7 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 8 | annotations: 9 | "helm.sh/resource-policy": keep 10 | namespace: {{ .Release.Namespace }} 11 | type: Opaque 12 | data: 13 | jwt-token.pem: {{ include "vc-authn-oidc.token.jwtToken" . | b64enc | quote }} 14 | {{- end }} 15 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/networkpolicy-agent-ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.acapy.networkPolicy.enabled .Values.acapy.networkPolicy.ingress.enabled -}} 2 | apiVersion: {{ include "common.capabilities.networkPolicy.apiVersion" . }} 3 | kind: NetworkPolicy 4 | metadata: 5 | name: {{ include "acapy.fullname" . }}-ingress 6 | labels: 7 | {{- include "acapy.labels" . | nindent 4 }} 8 | spec: 9 | podSelector: 10 | matchLabels: 11 | {{- include "acapy.selectorLabels" . | nindent 6 }} 12 | ingress: 13 | {{- if and .Values.ingress.enabled .Values.acapy.networkPolicy.ingress.enabled (or .Values.acapy.networkPolicy.ingress.namespaceSelector .Values.acapy.networkPolicy.ingress.podSelector) }} 14 | - from: 15 | {{- if .Values.acapy.networkPolicy.ingress.namespaceSelector }} 16 | - namespaceSelector: 17 | matchLabels: 18 | {{- include "common.tplvalues.render" (dict "value" .Values.acapy.networkPolicy.ingress.namespaceSelector "context" $) | nindent 14 }} 19 | {{- end }} 20 | {{- if .Values.acapy.networkPolicy.ingress.podSelector }} 21 | - podSelector: 22 | matchLabels: 23 | {{- include "common.tplvalues.render" (dict "value" .Values.acapy.networkPolicy.ingress.podSelector "context" $) | nindent 14 }} 24 | {{- end }} 25 | {{- end }} 26 | policyTypes: 27 | - Ingress 28 | {{- end -}} 29 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/networkpolicy-agent.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.networkPolicy.enabled -}} 2 | kind: NetworkPolicy 3 | apiVersion: {{ include "common.capabilities.networkPolicy.apiVersion" . }} 4 | metadata: 5 | name: {{ include "global.fullname" . }}-agent-webhook 6 | labels: 7 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 8 | spec: 9 | # Allow traffic from the agent to the controller 10 | ingress: 11 | - from: 12 | - podSelector: 13 | matchLabels: 14 | {{ include "acapy.selectorLabels" . | nindent 14 }} 15 | ports: 16 | - protocol: TCP 17 | port: {{ .Values.service.port }} 18 | podSelector: 19 | matchLabels: 20 | {{- include "vc-authn-oidc.selectorLabels" . | nindent 6 }} 21 | --- 22 | kind: NetworkPolicy 23 | apiVersion: {{ include "common.capabilities.networkPolicy.apiVersion" . }} 24 | metadata: 25 | name: {{ include "global.fullname" . }}-agent-access 26 | labels: 27 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 28 | spec: 29 | # Allow traffic from the controller to the agent 30 | ingress: 31 | - from: 32 | - podSelector: 33 | matchLabels: 34 | {{- include "vc-authn-oidc.selectorLabels" . | nindent 14 }} 35 | ports: 36 | - protocol: TCP 37 | port: {{ .Values.acapy.service.adminPort }} 38 | podSelector: 39 | matchLabels: 40 | {{ include "acapy.selectorLabels" . | nindent 6 }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/networkpolicy-db.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.networkPolicy.enabled -}} 2 | kind: NetworkPolicy 3 | apiVersion: {{ include "common.capabilities.networkPolicy.apiVersion" . }} 4 | metadata: 5 | name: {{ include "global.fullname" . }}-db 6 | labels: 7 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 8 | spec: 9 | # Allow traffic from the controller to the db, and between db pods 10 | ingress: 11 | - from: 12 | - podSelector: 13 | matchLabels: 14 | {{- include "vc-authn-oidc.selectorLabels" . | nindent 14 }} 15 | - podSelector: 16 | {{- with .Values.mongodb.commonLabels }} 17 | matchLabels: 18 | {{- toYaml . | nindent 14 }} 19 | {{- end }} 20 | ports: 21 | - protocol: TCP 22 | port: {{ .Values.mongodb.service.ports.mongodb }} 23 | podSelector: 24 | {{- with .Values.mongodb.commonLabels }} 25 | matchLabels: 26 | {{- toYaml . | nindent 6 }} 27 | {{- end }} 28 | --- 29 | kind: NetworkPolicy 30 | apiVersion: {{ include "common.capabilities.networkPolicy.apiVersion" . }} 31 | metadata: 32 | name: {{ include "acapy.fullname" . }}-db 33 | labels: 34 | {{- include "acapy.labels" . | nindent 4 }} 35 | spec: 36 | # Allow traffic from the agent to the db 37 | ingress: 38 | - from: 39 | - podSelector: 40 | matchLabels: 41 | {{- include "acapy.selectorLabels" . | nindent 14 }} 42 | ports: 43 | - protocol: TCP 44 | port: {{ .Values.postgresql.primary.service.ports.postgresql }} 45 | podSelector: 46 | {{- with .Values.postgresql.commonLabels }} 47 | matchLabels: 48 | {{- toYaml . | nindent 6 }} 49 | {{- end }} 50 | {{- end }} 51 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/networkpolicy-ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if or .Values.networkPolicy.enabled .Values.networkPolicy.ingress.enabled -}} 2 | kind: NetworkPolicy 3 | apiVersion: {{ include "common.capabilities.networkPolicy.apiVersion" . }} 4 | metadata: 5 | name: {{ include "global.fullname" . }}-ingress 6 | labels: 7 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 8 | spec: 9 | # Allow ingress traffic into the controller 10 | ingress: 11 | {{- if and .Values.ingress.enabled .Values.networkPolicy.ingress.enabled (or .Values.networkPolicy.ingress.namespaceSelector .Values.networkPolicy.ingress.podSelector) }} 12 | - from: 13 | {{- if .Values.networkPolicy.ingress.namespaceSelector }} 14 | - namespaceSelector: 15 | matchLabels: 16 | {{- include "common.tplvalues.render" (dict "value" .Values.networkPolicy.ingress.namespaceSelector "context" $) | nindent 14 }} 17 | {{- end }} 18 | {{- if .Values.networkPolicy.ingress.podSelector }} 19 | - podSelector: 20 | matchLabels: 21 | {{- include "common.tplvalues.render" (dict "value" .Values.networkPolicy.ingress.podSelector "context" $) | nindent 14 }} 22 | {{- end }} 23 | {{- end }} 24 | podSelector: 25 | matchLabels: 26 | {{- include "vc-authn-oidc.selectorLabels" . | nindent 6 }} 27 | policyTypes: 28 | - Ingress 29 | {{- end -}} 30 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/secrets.yaml: -------------------------------------------------------------------------------- 1 | {{- if (include "vc-authn-oidc.database.createSecret" .) -}} 2 | {{ $databaseSecretName := (include "vc-authn-oidc.databaseSecretName" .) }} 3 | {{ $mongoRootPassword := include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" $databaseSecretName "Key" "mongodb-root-password" "Length" 32) }} 4 | {{ $mongoReplicaSetKey := include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" $databaseSecretName "Key" "mongodb-replica-set-key" "Length" 32) }} 5 | {{ $mongoPasswords := include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" $databaseSecretName "Key" "mongodb-passwords" "Length" 32) }} 6 | apiVersion: v1 7 | kind: Secret 8 | metadata: 9 | name: {{ $databaseSecretName }} 10 | labels: 11 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 12 | annotations: 13 | "helm.sh/resource-policy": keep 14 | namespace: {{ .Release.Namespace }} 15 | type: Opaque 16 | data: 17 | mongodb-root-password: {{ $mongoRootPassword }} 18 | mongodb-replica-set-key: {{ $mongoReplicaSetKey }} 19 | mongodb-passwords: {{ $mongoPasswords }} 20 | {{- end }} 21 | --- 22 | {{- if (include "vc-authn-oidc.api.createSecret" .) -}} 23 | {{- $apiSecretName := include "vc-authn-oidc.apiSecretName" . -}} 24 | {{- $controllerApiKey := include "getOrGeneratePass" (dict "Namespace" .Release.Namespace "Kind" "Secret" "Name" $apiSecretName "Key" "controllerApiKey" "Length" 32) }} 25 | apiVersion: v1 26 | kind: Secret 27 | metadata: 28 | name: {{ $apiSecretName }} 29 | labels: 30 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 31 | annotations: 32 | "helm.sh/resource-policy": keep 33 | namespace: {{ .Release.Namespace }} 34 | type: Opaque 35 | data: 36 | controllerApiKey: {{ $controllerApiKey }} 37 | {{- end }} 38 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "global.fullname" . }} 5 | labels: 6 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "vc-authn-oidc.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/vc-authn-oidc/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "vc-authn-oidc.serviceAccountName" . }} 6 | labels: 7 | {{- include "vc-authn-oidc.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /demo/vue/.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | exclude_patterns: 3 | - config/ 4 | - db/ 5 | - dist/ 6 | - features/ 7 | - "**/node_modules/" 8 | - script/ 9 | - "**/spec/" 10 | - "**/test/" 11 | - "**/tests/" 12 | - Tests/ 13 | - "**/vendor/" 14 | - "**/*_test.go" 15 | - "**/*.d.ts" 16 | plugins: 17 | csslint: 18 | enabled: true 19 | editorconfig: 20 | enabled: true 21 | checks: 22 | END_OF_LINE: 23 | enabled: false 24 | INDENTATION_SPACES: 25 | enabled: false 26 | INDENTATION_SPACES_AMOUNT: 27 | enabled: false 28 | TRAILINGSPACES: 29 | enabled: false 30 | eslint: 31 | enabled: true 32 | channel: "eslint-7" 33 | config: 34 | config: app/.eslintrc.js 35 | fixme: 36 | enabled: true 37 | git-legal: 38 | enabled: true 39 | markdownlint: 40 | enabled: true 41 | checks: 42 | MD002: 43 | enabled: false 44 | MD013: 45 | enabled: false 46 | MD029: 47 | enabled: false 48 | MD046: 49 | enabled: false 50 | nodesecurity: 51 | enabled: true 52 | sass-lint: 53 | enabled: true 54 | -------------------------------------------------------------------------------- /demo/vue/.dockerignore: -------------------------------------------------------------------------------- 1 | # Editor directories and files 2 | .DS_Store 3 | .gradle 4 | .nyc_output 5 | .scannerwork 6 | build 7 | coverage 8 | dist 9 | files 10 | **/e2e/videos 11 | node_modules 12 | # Ignore only top-level package-lock.json 13 | /package-lock.json 14 | 15 | # Ignore Helm subcharts 16 | charts/**/charts 17 | Chart.lock 18 | 19 | # local env files 20 | local.* 21 | local-*.* 22 | .env.local 23 | .env.*.local 24 | 25 | # Log files 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # Editor directories and files 31 | .idea 32 | .vscode 33 | *.iml 34 | *.suo 35 | *.ntvs* 36 | *.njsproj 37 | *.sln 38 | *.sw? 39 | *.mp4 40 | 41 | # temp office files 42 | ~$* 43 | -------------------------------------------------------------------------------- /demo/vue/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.html] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.{css,js,json,jsx,scss,ts,tsx,vue}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [.{babelrc,eslintrc}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [Jenkinsfile*] 22 | indent_style = space 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /demo/vue/.gitattributes: -------------------------------------------------------------------------------- 1 | # Autodetect text files and forces unix eols, so Windows does not break them 2 | * text=auto eol=lf 3 | 4 | # Force images/fonts to be handled as binaries 5 | *.jpg binary 6 | *.jpeg binary 7 | *.gif binary 8 | *.png binary 9 | -------------------------------------------------------------------------------- /demo/vue/.gitignore: -------------------------------------------------------------------------------- 1 | # Editor directories and files 2 | .DS_Store 3 | .gradle 4 | .nyc_output 5 | .scannerwork 6 | build 7 | coverage 8 | dist 9 | **/e2e/videos 10 | node_modules 11 | # Ignore only top-level package-lock.json 12 | /package-lock.json 13 | 14 | # local env files 15 | local.* 16 | local-*.* 17 | .env.local 18 | .env.*.local 19 | 20 | # Log files 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Editor directories and files 26 | .idea 27 | .vscode 28 | *.iml 29 | *.suo 30 | *.ntvs* 31 | *.njsproj 32 | *.sln 33 | *.sw? 34 | *.mp4 35 | 36 | # temp office files 37 | ~$* 38 | -------------------------------------------------------------------------------- /demo/vue/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/node:20-bookworm 2 | ENV NO_UPDATE_NOTIFIER=true 3 | WORKDIR /opt/app-root/src/app 4 | COPY . /opt/app-root/src 5 | RUN npm run all:ci \ 6 | && npm run all:build \ 7 | && npm run frontend:purge 8 | EXPOSE 8000 9 | CMD ["npm", "run", "start"] 10 | -------------------------------------------------------------------------------- /demo/vue/README.md: -------------------------------------------------------------------------------- 1 | # ACAPy VC-AuthN OIDC Demo App 2 | -------------------------------------------------------------------------------- /demo/vue/app/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.html] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.{css,js,json,jsx,scss,ts,tsx,vue}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [.{babelrc,eslintrc}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [Jenkinsfile*] 22 | indent_style = space 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /demo/vue/app/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | frontend 3 | node_modules 4 | public/js 5 | -------------------------------------------------------------------------------- /demo/vue/app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | commonjs: true, 5 | es6: true, 6 | jest: true, 7 | node: true 8 | }, 9 | extends: ['eslint:recommended'], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly', 13 | _: false 14 | }, 15 | parserOptions: { 16 | ecmaVersion: 9 17 | }, 18 | rules: { 19 | 'eol-last': ['error', 'always'], 20 | indent: ['error', 2, { 21 | 'SwitchCase': 1 22 | }], 23 | 'linebreak-style': ['error', 'unix'], 24 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 25 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 26 | quotes: ['error', 'single'], 27 | semi: ['error', 'always'] 28 | }, 29 | overrides: [ 30 | { 31 | files: [ 32 | '**/__tests__/*.{j,t}s?(x)', 33 | '**/tests/unit/**/*.spec.{j,t}s?(x)' 34 | ], 35 | env: { 36 | jest: true 37 | } 38 | } 39 | ] 40 | }; 41 | -------------------------------------------------------------------------------- /demo/vue/app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /demo/vue/app/README.md: -------------------------------------------------------------------------------- 1 | # Vue Scaffold Application 2 | 3 | This node.js scaffold app hosts the Vue scaffold frontend. It implements a minimal endpoint to allow for Keycloak authentication. 4 | 5 | ## Configuration 6 | 7 | The Vue scaffold app will require some configuration. The API will be locked down and require a valid JWT Token to access. We will need to configure the application to authenticate using the same Keycloak realm as the [frontend](frontend). Note that the Vue scaffold frontend is currently designed to expect all associated resources to be relative to the original access path. 8 | 9 | ## Super Quickstart 10 | 11 | In general, most of these npm run scripts can be prepended with `all:` in order to run the same operation on both the application and the frontend sequentially. 12 | 13 | ### Local Config Setup 14 | 15 | Ensure that you have filled in all the appropriate configurations following [config/custom-environment-variables.json](config/custom-environment-variables.json) before proceeding. 16 | 17 | The [config/custom-environment-variables.json](config/custom-environment-variables.json) file provides a complete mapping of ENV variables to the config that the node application will see, and the [config/default.json](config/default.json)default.json provides generic defaults for all non-sensitive config values. If you do a search for `config.get(...)` on the repository, you'll get a sense of how the configuration variables are utilized in this project. 18 | 19 | If you are running this on a local machine, you will need to create a `local.json` file in the `config` directory containing the values you want set. For more information on how the config library loads and searches for environment variables, take a look at this article: . 20 | 21 | At an absolute bare minimum, we recommend that you will want your `local.json` to at least have the following values defined (replacing `REDACTED` with your own values as needed): 22 | 23 | ``` json 24 | { 25 | "frontend": { 26 | "keycloak": { 27 | "clientId": "REDACTED", 28 | "realm": "REDACTED", 29 | "serverUrl": "REDACTED" 30 | } 31 | }, 32 | "server": { 33 | "keycloak": { 34 | "clientId": "REDACTED", 35 | "clientSecret": "REDACTED", 36 | "realm": "REDACTED", 37 | "serverUrl": "REDACTED" 38 | }, 39 | "logLevel": "debug", 40 | "morganFormat": "dev", 41 | "port": "8080" 42 | } 43 | } 44 | ``` 45 | 46 | ### Production Build and Run 47 | 48 | ``` sh 49 | npm run all:fresh-start 50 | ``` 51 | 52 | ### Development Run 53 | 54 | ``` sh 55 | npm run serve 56 | ``` 57 | 58 | Start a new terminal 59 | 60 | ``` sh 61 | cd frontend 62 | npm run serve 63 | ``` 64 | 65 | ### Run application tests 66 | 67 | ``` sh 68 | npm run test 69 | ``` 70 | 71 | ### Lints and fixes application files 72 | 73 | ``` sh 74 | npm run lint 75 | npm run lint-fix 76 | ``` 77 | -------------------------------------------------------------------------------- /demo/vue/app/app.js: -------------------------------------------------------------------------------- 1 | const compression = require('compression'); 2 | const config = require('config'); 3 | const express = require('express'); 4 | const path = require('path'); 5 | const Problem = require('api-problem'); 6 | const querystring = require('querystring'); 7 | 8 | const keycloak = require('./src/components/keycloak'); 9 | const log = require('./src/components/log')(module.filename); 10 | const httpLogger = require('./src/components/log').httpLogger; 11 | const v1Router = require('./src/routes/v1'); 12 | 13 | const apiRouter = express.Router(); 14 | const state = { 15 | ready: true, // No dependencies so application is always ready 16 | shutdown: false 17 | }; 18 | 19 | const app = express(); 20 | app.use(compression()); 21 | app.use(express.json({ limit: config.get('server.bodyLimit') })); 22 | app.use(express.urlencoded({ extended: true })); 23 | 24 | // Skip if running tests 25 | if (process.env.NODE_ENV !== 'test') { 26 | app.use(httpLogger); 27 | } 28 | 29 | // Use Keycloak OIDC Middleware 30 | app.use(keycloak.middleware()); 31 | 32 | // Block requests until service is ready 33 | app.use((_req, res, next) => { 34 | if (state.shutdown) { 35 | new Problem(503, { details: 'Server is shutting down' }).send(res); 36 | } else if (!state.ready) { 37 | new Problem(503, { details: 'Server is not ready' }).send(res); 38 | } else { 39 | next(); 40 | } 41 | }); 42 | 43 | // Frontend configuration endpoint 44 | apiRouter.use('/config', (_req, res, next) => { 45 | try { 46 | const frontend = config.get('frontend'); 47 | res.status(200).json(frontend); 48 | } catch (err) { 49 | next(err); 50 | } 51 | }); 52 | 53 | // Base API Directory 54 | apiRouter.get('/api', (_req, res) => { 55 | if (state.shutdown) { 56 | throw new Error('Server shutting down'); 57 | } else { 58 | res.status(200).json('ok'); 59 | } 60 | }); 61 | 62 | // Host API endpoints 63 | apiRouter.use(config.get('server.apiPath'), v1Router); 64 | app.use(config.get('server.basePath'), apiRouter); 65 | 66 | // Host the static frontend assets 67 | const staticFilesPath = config.get('frontend.basePath'); 68 | app.use('/favicon.ico', (_req, res) => { res.redirect(`${staticFilesPath}/favicon.ico`); }); 69 | app.use(staticFilesPath, express.static(path.join(__dirname, 'frontend/dist'))); 70 | 71 | // Handle 500 72 | // eslint-disable-next-line no-unused-vars 73 | app.use((err, _req, res, _next) => { 74 | if (err.stack) { 75 | log.error(err); 76 | } 77 | 78 | if (err instanceof Problem) { 79 | err.send(res, null); 80 | } else { 81 | new Problem(500, 'Server Error', { 82 | detail: (err.message) ? err.message : err 83 | }).send(res); 84 | } 85 | }); 86 | 87 | // Handle 404 88 | app.use((req, res) => { 89 | if (req.originalUrl.startsWith(`${config.get('server.basePath')}/api`)) { 90 | // Return a 404 problem if attempting to access API 91 | new Problem(404, 'Page Not Found', { 92 | detail: req.originalUrl 93 | }).send(res); 94 | } else { 95 | // Redirect any non-API requests to static frontend with redirect breadcrumb 96 | const query = querystring.stringify({ ...req.query, r: req.path }); 97 | res.redirect(`${staticFilesPath}/?${query}`); 98 | } 99 | }); 100 | 101 | // Prevent unhandled errors from crashing application 102 | process.on('unhandledRejection', err => { 103 | if (err && err.stack) { 104 | log.error(err); 105 | } 106 | }); 107 | 108 | // Graceful shutdown support 109 | process.on('SIGTERM', shutdown); 110 | process.on('SIGINT', shutdown); 111 | process.on('SIGUSR1', shutdown); 112 | process.on('SIGUSR2', shutdown); 113 | process.on('exit', () => { 114 | log.info('Exiting...'); 115 | }); 116 | 117 | /** 118 | * @function shutdown 119 | * Shuts down this application after at least 3 seconds. 120 | */ 121 | function shutdown() { 122 | log.info('Received kill signal. Shutting down...'); 123 | // Wait 3 seconds before starting cleanup 124 | if (!state.shutdown) setTimeout(cleanup, 3000); 125 | } 126 | 127 | /** 128 | * @function cleanup 129 | * Cleans up connections in this application. 130 | */ 131 | function cleanup() { 132 | log.info('Service no longer accepting traffic'); 133 | state.shutdown = true; 134 | process.exit(); 135 | } 136 | 137 | module.exports = app; 138 | -------------------------------------------------------------------------------- /demo/vue/app/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** Module dependencies */ 4 | const config = require('config'); 5 | const http = require('http'); 6 | 7 | const app = require('../app'); 8 | const log = require('../src/components/log')(module.filename); 9 | 10 | /** Normalize a port into a number, string, or false. */ 11 | const normalizePort = val => { 12 | const port = parseInt(val, 10); 13 | 14 | if (isNaN(port)) { 15 | // named pipe 16 | return val; 17 | } 18 | 19 | if (port >= 0) { 20 | // port number 21 | return port; 22 | } 23 | 24 | return false; 25 | }; 26 | 27 | /** Event listener for HTTP server "error" event. */ 28 | const onError = error => { 29 | if (error.syscall !== 'listen') { 30 | throw error; 31 | } 32 | 33 | const bind = typeof port === 'string' ? 34 | 'Pipe ' + port : 35 | 'Port ' + port; 36 | 37 | // handle specific listen errors with friendly messages 38 | switch (error.code) { 39 | case 'EACCES': 40 | log.error(bind + ' requires elevated privileges'); 41 | process.exit(1); 42 | break; 43 | case 'EADDRINUSE': 44 | log.error(bind + ' is already in use'); 45 | process.exit(1); 46 | break; 47 | default: 48 | throw error; 49 | } 50 | }; 51 | 52 | /** Event listener for HTTP server "listening" event. */ 53 | const onListening = () => { 54 | const addr = server.address(); 55 | const bind = typeof addr === 'string' ? 56 | 'pipe ' + addr : 57 | 'port ' + addr.port; 58 | log.info('Listening on ' + bind); 59 | }; 60 | 61 | /** Get port from environment and store in Express. */ 62 | const port = normalizePort(config.get('server.port')); 63 | app.set('port', port); 64 | 65 | /** Create HTTP server. */ 66 | const server = http.createServer(app); 67 | 68 | /** Listen on provided port, on all network interfaces. */ 69 | server.listen(port); 70 | server.on('error', onError); 71 | server.on('listening', onListening); 72 | -------------------------------------------------------------------------------- /demo/vue/app/config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "frontend": { 3 | "apiPath": "FRONTEND_APIPATH", 4 | "basePath": "FRONTEND_BASEPATH", 5 | "keycloak": { 6 | "clientId": "FRONTEND_KC_CLIENTID", 7 | "realm": "FRONTEND_KC_REALM", 8 | "serverUrl": "FRONTEND_KC_SERVERURL", 9 | "presReqConfId": "FRONTEND_KC_PRES_REQ_CONF_ID" 10 | } 11 | }, 12 | "server": { 13 | "apiPath": "SERVER_APIPATH", 14 | "basePath": "SERVER_BASEPATH", 15 | "bodyLimit": "SERVER_BODYLIMIT", 16 | "keycloak": { 17 | "clientId": "SERVER_KC_CLIENTID", 18 | "clientSecret": "SERVER_KC_CLIENTSECRET", 19 | "publicKey": "SERVER_KC_PUBLICKEY", 20 | "realm": "SERVER_KC_REALM", 21 | "serverUrl": "SERVER_KC_SERVERURL" 22 | }, 23 | "logFile": "SERVER_LOGFILE", 24 | "logLevel": "SERVER_LOGLEVEL", 25 | "port": "SERVER_PORT" 26 | } 27 | } -------------------------------------------------------------------------------- /demo/vue/app/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "frontend": { 3 | "apiPath": "api/v1", 4 | "basePath": "/app", 5 | "keycloak": { 6 | "serverUrl": "" 7 | } 8 | }, 9 | "server": { 10 | "apiPath": "/api/v1", 11 | "basePath": "/app", 12 | "bodyLimit": "30mb", 13 | "keycloak": { 14 | "serverUrl": "" 15 | }, 16 | "logLevel": "http", 17 | "port": "8080" 18 | } 19 | } -------------------------------------------------------------------------------- /demo/vue/app/config/production.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /demo/vue/app/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "frontend": { 3 | "keycloak": { 4 | "clientId": "client-frontend-local", 5 | "realm": "01234567" 6 | } 7 | }, 8 | "server": { 9 | "keycloak": { 10 | "clientId": "client", 11 | "realm": "01234567", 12 | "clientSecret": "password" 13 | }, 14 | "logLevel": "silent" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/.env.development: -------------------------------------------------------------------------------- 1 | # development environment env settings 2 | # vue cli will run with env=development by default when running vue-cli-service serve (npm run serve) 3 | # these values can be overridden locally by creating .env.development.local file (which will not commit to git) 4 | 5 | VUE_APP_TITLE=Vue Skeleton App - DEVELOPMENT 6 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/.env.test: -------------------------------------------------------------------------------- 1 | # test mode env settings 2 | # should generally only run when npm run tests is executed (ie, not actually in the "test" server) 3 | 4 | VUE_APP_TITLE=Vue Skeleton App 5 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | public/js 5 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | jest: true, 6 | node: true, 7 | }, 8 | extends: ['plugin:vue/essential', 'eslint:recommended'], 9 | plugins: ['vuetify'], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly', 13 | _: false, 14 | }, 15 | parserOptions: { 16 | parser: '@babel/eslint-parser', 17 | ecmaVersion: 8, 18 | }, 19 | rules: { 20 | 'eol-last': ['error', 'always'], 21 | indent: [ 22 | 'error', 23 | 2, 24 | { 25 | SwitchCase: 1, 26 | }, 27 | ], 28 | 'linebreak-style': ['error', 'unix'], 29 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 30 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 31 | quotes: ['error', 'single'], 32 | semi: ['error', 'always'], 33 | 'vue/html-closing-bracket-newline': [ 34 | 'off', 35 | { 36 | singleline: 'never', 37 | multiline: 'never', 38 | }, 39 | ], 40 | 'vue/html-indent': [ 41 | 'error', 42 | 2, 43 | { 44 | attribute: 1, 45 | baseIndent: 1, 46 | closeBracket: 0, 47 | alignAttributesVertically: true, 48 | ignores: [], 49 | }, 50 | ], 51 | 'vue/max-attributes-per-line': [ 52 | 'off', 53 | { 54 | singleline: 1, 55 | multiline: { 56 | max: 1, 57 | allowFirstLine: true, 58 | }, 59 | }, 60 | ], 61 | 'vue/multi-word-component-names': 'off', 62 | 'vue/no-multi-spaces': [ 63 | 'error', 64 | { 65 | ignoreProperties: false, 66 | }, 67 | ], 68 | 'vue/no-spaces-around-equal-signs-in-attribute': ['error'], 69 | 'vuetify/no-deprecated-classes': 'error', 70 | 'vuetify/grid-unknown-attributes': 'error', 71 | 'vuetify/no-legacy-grid': 'error', 72 | }, 73 | overrides: [ 74 | { 75 | files: [ 76 | '**/__tests__/*.{j,t}s?(x)', 77 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 78 | ], 79 | env: { 80 | jest: true, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /demo/vue/app/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue Skeleton Frontend 2 | 3 | This is the Vue Skeleton frontend. It implements a Vue frontend with Keycloak authentication support. 4 | 5 | ## Configuration 6 | 7 | The Vue Skeleton frontend will require some configuration. The API it invokes will be locked down and require a valid JWT Token to access. We will need to configure the application to authenticate using the same Keycloak realm as the [app](../). Note that the Vue Skeleton frontend is currently designed to expect all associated resources to be relative to the original access path. 8 | 9 | ## Super Quickstart 10 | 11 | Ensure that you have filled in all the appropriate configurations following [../config/custom-environment-variables.json](../config/custom-environment-variables.json) before proceeding. 12 | 13 | ### Project setup 14 | 15 | ``` sh 16 | npm install 17 | ``` 18 | 19 | ### Compiles and hot-reloads for development 20 | 21 | ``` sh 22 | npm run serve 23 | ``` 24 | 25 | ### Compiles and minifies for production 26 | 27 | ``` sh 28 | npm run build 29 | ``` 30 | 31 | ### Run your unit tests 32 | 33 | ``` sh 34 | npm run test:unit 35 | ``` 36 | 37 | ### Lints and fixes files 38 | 39 | ``` sh 40 | npm run lint 41 | ``` 42 | 43 | ### Customize configuration 44 | 45 | See [Configuration Reference](https://cli.vuejs.org/config/). 46 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | }; 4 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: ['src/**/*.{js,vue}', '!src/main.js', '!src/plugins/*.*'], 5 | moduleFileExtensions: ['js', 'json', 'vue', 'jsx'], 6 | moduleNameMapper: { 7 | '^@/(.*)$': '/src/$1' 8 | }, 9 | preset: '@vue/cli-plugin-unit-jest', 10 | setupFiles: ['/tests/unit/globalSetup.js'], 11 | snapshotSerializers: ['jest-serializer-vue'], 12 | testMatch: [ 13 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 14 | ], 15 | testURL: 'http://localhost/', 16 | transform: { 17 | '.*\\.(vue)$': 'vue-jest', 18 | '^.+\\.vue$': 'vue-jest', 19 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 20 | 'jest-transform-stub', 21 | '^.+\\.jsx?$': 'babel-jest' 22 | }, 23 | transformIgnorePatterns: ['/node_modules/'], 24 | watchPlugins: [ 25 | 'jest-watch-typeahead/filename', 26 | 'jest-watch-typeahead/testname' 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/lcov-fix.js: -------------------------------------------------------------------------------- 1 | // Jest 25.x onwards emits coverage reports on a different source path 2 | // https://stackoverflow.com/q/60323177 3 | const fs = require('fs'); 4 | const file = './coverage/lcov.info'; 5 | 6 | fs.readFile(file, 'utf8', (err, data) => { 7 | if (err) { 8 | return console.error(err); // eslint-disable-line no-console 9 | } 10 | const result = data.replace(/src/g, `${process.cwd()}/src`); 11 | 12 | fs.writeFile(file, result, 'utf8', err => { 13 | if (err) return console.error(err); // eslint-disable-line no-console 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-scaffold-frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "serve": "vue-cli-service serve", 8 | "build": "vue-cli-service build", 9 | "test": "npm run test:unit", 10 | "test:unit": "vue-cli-service test:unit --verbose --verbose --forceExit --detectOpenHandles", 11 | "lint": "vue-cli-service lint", 12 | "lint:fix": "vue-cli-service lint --fix", 13 | "pretest": "npm run lint", 14 | "clean": "rm -rf coverage dist **/e2e/videos", 15 | "purge": "rm -rf node_modules", 16 | "rebuild": "npm run clean && npm run build", 17 | "reinstall": "npm run purge && npm install" 18 | }, 19 | "dependencies": { 20 | "@babel/eslint-parser": "^7.27.1", 21 | "@vue/eslint-config-prettier": "^10.2.0", 22 | "axios": "^1.9.0", 23 | "core-js": "^3.42.0", 24 | "eslint": "^8.57.0", 25 | "eslint-plugin-prettier": "^5.4.0", 26 | "keycloak-js": "^15.1.1", 27 | "nprogress": "^0.2.0", 28 | "vue": "^2.7.16", 29 | "vue-router": "^3.6.5", 30 | "vuetify": "^2.7.2", 31 | "vuex": "^3.6.2" 32 | }, 33 | "devDependencies": { 34 | "@vue/cli-plugin-babel": "^5.0.8", 35 | "@vue/cli-plugin-eslint": "^5.0.8", 36 | "@vue/cli-plugin-router": "^5.0.8", 37 | "@vue/cli-plugin-unit-jest": "^5.0.8", 38 | "@vue/cli-service": "^5.0.8", 39 | "@vue/test-utils": "^1.3.0", 40 | "axios-mock-adapter": "^2.1.0", 41 | "eslint-plugin-vue": "^9.32.0", 42 | "eslint-plugin-vuetify": "^1.1.0", 43 | "lodash": "^4.17.21", 44 | "prettier": "^3.5.3", 45 | "sass": "^1.87.0", 46 | "sass-loader": "^14.2.1", 47 | "vue-cli-plugin-vuetify": "^2.5.8", 48 | "vue-template-compiler": "^2.7.16", 49 | "vuetify-loader": "^1.9.2" 50 | }, 51 | "optionalDependencies": { 52 | "fsevents": "^2.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/demo/vue/app/frontend/public/favicon.ico -------------------------------------------------------------------------------- /demo/vue/app/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= process.env.VUE_APP_TITLE %> 9 | 10 | 11 | 12 | 13 | 14 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 30 | 31 | 41 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/assets/scss/style.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This globally extends the base Vuetify style 3 | */ 4 | @import '~vuetify/src/styles/styles.sass'; 5 | 6 | // Variables 7 | $custom-blue: #4066B2; 8 | $custom-blue-secondary: #003399; 9 | $custom-lightblue: #BFCBE5; 10 | $custom-grey: #efefef; 11 | $custom-font: #252525; 12 | 13 | // Sticky Footer 14 | body { 15 | display: flex; 16 | flex-direction: column; 17 | & > .v-content { 18 | flex: 1 0 auto; 19 | } 20 | } 21 | 22 | // Typography 23 | .v-application { 24 | font-family: -apple-system, BlinkMacSystemFont, BCSans, Roboto, Verdana, Arial, sans-serif !important; 25 | line-height: 1.4; 26 | font-size: 0.875rem; 27 | } 28 | 29 | h1 { font-size: 1.9em; } 30 | h2 { font-size: 1.7em; } 31 | h3 { font-size: 1.3em; } 32 | h4 { font-size: 1.05em; } 33 | 34 | h1, h2, h3, h4, h5 { 35 | color: $custom-font; 36 | line-height: 1.2; 37 | } 38 | 39 | p { 40 | color: $custom-font; 41 | line-height: 1.4; 42 | } 43 | 44 | // Horizontal Rule 45 | hr { 46 | border-top: 1px solid lightgrey; 47 | margin-bottom: 1em; 48 | .orange { 49 | border-top: 2px solid $custom-lightblue; 50 | } 51 | } 52 | 53 | // Anchor and Tab Behavior 54 | a, .v-tab { 55 | text-decoration: none; 56 | &:hover { 57 | text-decoration: underline; 58 | button, .v-icon { 59 | display: inline-block; 60 | text-decoration: none; 61 | } 62 | } 63 | } 64 | 65 | // General Button Style Extensions 66 | .v-btn { 67 | span > span { 68 | font-weight: bold; 69 | } 70 | 71 | &:hover { 72 | opacity: 0.8; 73 | span > span { 74 | text-decoration: underline; 75 | } 76 | } 77 | 78 | &.v-btn--disabled { 79 | background-color: #eee; 80 | color: #777; 81 | } 82 | 83 | &.v-btn--outlined { 84 | &.theme--light { 85 | border: 2px solid #003366; 86 | } 87 | 88 | &.theme--dark { 89 | border: 2px solid #fff; 90 | } 91 | } 92 | } 93 | 94 | // Stepper 95 | .silv-stepper { 96 | .header-row { 97 | background-color: $custom-grey !important; 98 | } 99 | &.v-stepper--alt-labels { 100 | .v-stepper__step { 101 | padding-left: 0; 102 | padding-right: 0; 103 | .v-stepper__label { 104 | color: $custom-font; 105 | font-size: .75em; 106 | } 107 | &.v-stepper__step--active { 108 | .v-stepper__label{ 109 | color: $custom-font; 110 | font-weight: bold; 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/HelloCall.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 114 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/base/BaseAuthButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 52 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/base/BaseDialog.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 77 | 78 | 93 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/base/BaseSecure.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 58 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/owf/OWFFooter.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | 24 | 51 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/owf/OWFHeader.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 69 | 70 | 99 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/components/owf/OWFNavBar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 21 | 22 | 75 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import 'nprogress/nprogress.css'; 2 | import '@/assets/scss/style.scss'; 3 | 4 | import axios from 'axios'; 5 | import NProgress from 'nprogress'; 6 | import Vue from 'vue'; 7 | 8 | import App from '@/App.vue'; 9 | import auth from '@/store/modules/auth.js'; 10 | import getRouter from '@/router'; 11 | import store from '@/store'; 12 | import VueKeycloakJs from '@/plugins/keycloak'; 13 | import vuetify from '@/plugins/vuetify'; 14 | 15 | Vue.config.productionTip = false; 16 | 17 | NProgress.configure({ showSpinner: false }); 18 | NProgress.start(); 19 | 20 | // Globally register all components with base in the name 21 | const requireComponent = require.context( 22 | '@/components', 23 | true, 24 | /Base[A-Z]\w+\.(vue|js)$/ 25 | ); 26 | requireComponent.keys().forEach((fileName) => { 27 | const componentConfig = requireComponent(fileName); 28 | const componentName = fileName 29 | .split('/') 30 | .pop() 31 | .replace(/\.\w+$/, ''); 32 | Vue.component(componentName, componentConfig.default || componentConfig); 33 | }); 34 | 35 | loadConfig(); 36 | 37 | /** 38 | * @function initializeApp 39 | * Initializes and mounts the Vue instance 40 | * @param {boolean} [kcSuccess=false] is Keycloak initialized successfully? 41 | * @param {string} [basepath='/'] base server path 42 | */ 43 | function initializeApp(kcSuccess = false, basePath = '/') { 44 | if (kcSuccess && !store.hasModule('auth')) store.registerModule('auth', auth); 45 | 46 | new Vue({ 47 | router: getRouter(basePath), 48 | store, 49 | vuetify, 50 | render: (h) => h(App), 51 | }).$mount('#app'); 52 | 53 | NProgress.done(); 54 | } 55 | 56 | /** 57 | * @function loadConfig 58 | * Acquires the configuration state from the backend server 59 | */ 60 | async function loadConfig() { 61 | // App publicPath is ./ - so use relative path here, will hit the backend server using relative path to root. 62 | const configUrl = 63 | process.env.NODE_ENV === 'production' ? 'config' : '/app/config'; 64 | const storageKey = 'config'; 65 | 66 | try { 67 | // Get configuration if it isn't already in session storage 68 | if (sessionStorage.getItem(storageKey) === null) { 69 | const { data } = await axios.get(configUrl); 70 | sessionStorage.setItem(storageKey, JSON.stringify(data)); 71 | } 72 | 73 | // Mount the configuration as a prototype for easier access from Vue 74 | const config = JSON.parse(sessionStorage.getItem(storageKey)); 75 | Vue.prototype.$config = Object.freeze(config); 76 | 77 | if ( 78 | !config || 79 | !config.keycloak || 80 | !config.keycloak.clientId || 81 | !config.keycloak.realm || 82 | !config.keycloak.serverUrl 83 | ) { 84 | throw new Error('Keycloak is misconfigured'); 85 | } 86 | 87 | loadKeycloak(config); 88 | } catch (err) { 89 | sessionStorage.removeItem(storageKey); 90 | initializeApp(false); // Attempt to gracefully fail 91 | throw new Error(`Failed to acquire configuration: ${err.message}`); 92 | } 93 | } 94 | 95 | /** 96 | * @function loadKeycloak 97 | * Applies Keycloak authentication capabilities 98 | * @param {object} config A config object 99 | */ 100 | function loadKeycloak(config) { 101 | Vue.use(VueKeycloakJs, { 102 | init: { onLoad: 'check-sso', flow: 'standard', pkceMethod: 'S256' }, 103 | config: { 104 | clientId: config.keycloak.clientId, 105 | realm: config.keycloak.realm, 106 | url: config.keycloak.serverUrl, 107 | }, 108 | onReady: () => { 109 | initializeApp(true, config.basePath); 110 | }, 111 | onInitError: (error) => { 112 | console.error('Keycloak failed to initialize'); // eslint-disable-line no-console 113 | console.error(error); // eslint-disable-line no-console 114 | }, 115 | }); 116 | } 117 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({ 7 | defaultAssets: { 8 | font: true, 9 | icons: 'md' 10 | }, 11 | icons: { 12 | iconfont: 'md', 13 | }, 14 | theme: { 15 | options: { 16 | customProperties: true 17 | }, 18 | themes: { 19 | light: { 20 | primary: '#003366', 21 | secondary: '#FCBA19', 22 | anchor: '#1A5A96', 23 | accent: '#82B1FF', 24 | error: '#D8292F', 25 | info: '#2196F3', 26 | success: '#2E8540', 27 | warning: '#FFC107' 28 | } 29 | } 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import NProgress from 'nprogress'; 2 | import Vue from 'vue'; 3 | import VueRouter from 'vue-router'; 4 | 5 | Vue.use(VueRouter); 6 | 7 | let isFirstTransition = true; 8 | 9 | /** 10 | * Constructs and returns a Vue Router object 11 | * @param {string} [basePath='/'] the base server path 12 | * @returns {object} a Vue Router object 13 | */ 14 | export default function getRouter(basePath = '/') { 15 | const router = new VueRouter({ 16 | base: basePath, 17 | mode: 'history', 18 | routes: [ 19 | { 20 | path: '/', 21 | redirect: { name: 'Home' }, 22 | }, 23 | { 24 | path: '/', 25 | name: 'Home', 26 | component: () => 27 | import(/* webpackChunkName: "home" */ '@/views/Home.vue'), 28 | meta: { 29 | hasLogin: true, 30 | }, 31 | }, 32 | { 33 | path: '/secure', 34 | name: 'Secure', 35 | component: () => 36 | import(/* webpackChunkName: "secure" */ '@/views/Secure.vue'), 37 | meta: { 38 | hasLogin: true, 39 | requiresAuth: true, 40 | }, 41 | }, 42 | { 43 | path: '/404', 44 | alias: '*', 45 | name: 'NotFound', 46 | component: () => 47 | import(/* webpackChunkName: "not-found" */ '@/views/NotFound.vue'), 48 | meta: { 49 | hasLogin: true, 50 | }, 51 | }, 52 | ], 53 | }); 54 | 55 | router.beforeEach((to, _from, next) => { 56 | NProgress.start(); 57 | if ( 58 | to.matched.some((route) => route.meta.requiresAuth) && 59 | router.app.$keycloak && 60 | router.app.$keycloak.ready && 61 | !router.app.$keycloak.authenticated 62 | ) { 63 | const redirect = location.origin + basePath + to.path + location.search; 64 | const loginUrl = router.app.$keycloak.createLoginUrl({ 65 | redirectUri: redirect, 66 | }); 67 | window.location.replace( 68 | loginUrl + 69 | '&pres_req_conf_id=' + 70 | Vue.prototype.$config.keycloak.presReqConfId 71 | ); 72 | } 73 | if ( 74 | Vue.prototype.$keycloak.tokenParsed && 75 | Vue.prototype.$keycloak.tokenParsed.pres_req_conf_id && 76 | Vue.prototype.$keycloak.tokenParsed.pres_req_conf_id != 77 | Vue.prototype.$config.keycloak.presReqConfId 78 | ) { 79 | // console.log('PRES_REQ_CONF_ID mismatch'); 80 | // if satisified request was NOT the configured request, login is invalid 81 | const redirect = location.origin + basePath; 82 | const logoutUrl = router.app.$keycloak.createLogoutUrl({ 83 | redirectUri: redirect, 84 | }); 85 | window.location.replace(logoutUrl); 86 | } else { 87 | if (to.meta.title || process.env.VUE_APP_TITLE) { 88 | document.title = to.meta.title 89 | ? to.meta.title 90 | : process.env.VUE_APP_TITLE; 91 | } else document.title = 'Demo ACAPy VC-Authn-OIDC App'; // default title 92 | 93 | if (to.query.r && isFirstTransition) { 94 | router.replace({ 95 | path: to.query.r.replace(basePath, ''), 96 | query: (({ r, ...q }) => q)(to.query), // eslint-disable-line no-unused-vars 97 | }); 98 | } 99 | next(); 100 | } 101 | }); 102 | 103 | router.afterEach(() => { 104 | isFirstTransition = false; 105 | NProgress.done(); 106 | }); 107 | 108 | return router; 109 | } 110 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/services/helloService.js: -------------------------------------------------------------------------------- 1 | import { appAxios } from '@/services/interceptors'; 2 | import { ApiRoutes } from '@/utils/constants'; 3 | 4 | export default { 5 | /** 6 | * @function getHello 7 | * Fetch the contents of the hello endpoint 8 | * @returns {Promise} An axios response 9 | */ 10 | getHello() { 11 | return appAxios().get(ApiRoutes.HELLO); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/services/index.js: -------------------------------------------------------------------------------- 1 | export { default as helloService } from './helloService'; 2 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/services/interceptors.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Vue from 'vue'; 3 | 4 | /** 5 | * @function appAxios 6 | * Returns an Axios instance with auth header and preconfiguration 7 | * @param {integer} [timeout=10000] Number of milliseconds before timing out the request 8 | * @returns {object} An axios instance 9 | */ 10 | export function appAxios(timeout = 10000) { 11 | const axiosOptions = { timeout: timeout }; 12 | if (Vue.prototype.$config) { 13 | const config = Vue.prototype.$config; 14 | axiosOptions.baseURL = `${config.basePath}/${config.apiPath}`; 15 | } 16 | 17 | const instance = axios.create(axiosOptions); 18 | 19 | instance.interceptors.request.use(cfg => { 20 | if (Vue.prototype.$keycloak && 21 | Vue.prototype.$keycloak.ready && 22 | Vue.prototype.$keycloak.authenticated) { 23 | cfg.headers.Authorization = `Bearer ${Vue.prototype.$keycloak.token}`; 24 | } 25 | return Promise.resolve(cfg); 26 | }, error => { 27 | return Promise.reject(error); 28 | }); 29 | 30 | return instance; 31 | } 32 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | Vue.use(Vuex); 5 | 6 | export default new Vuex.Store({ 7 | modules: {}, 8 | state: {}, 9 | mutations: {}, 10 | actions: {} 11 | }); 12 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/store/modules/auth.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | /** 4 | * @function hasRoles 5 | * Checks if all elements in `roles` array exists in `tokenRoles` array 6 | * @param {string[]} tokenRoles An array of roles that exist in the token 7 | * @param {string[]} roles An array of roles to check 8 | * @returns {boolean} True if all `roles` exist in `tokenRoles`; false otherwise 9 | */ 10 | function hasRoles(tokenRoles, roles = []) { 11 | return roles 12 | .map((r) => tokenRoles.some((t) => t === r)) 13 | .every((x) => x === true); 14 | } 15 | 16 | export default { 17 | namespaced: true, 18 | state: { 19 | // In most cases, when this becomes populated, we end up doing a redirect flow, 20 | // so when we return to the app, it is fresh again and undefined 21 | redirectUri: undefined, 22 | presReqConfId: 'test-proof', //TODO: load this via config response 23 | }, 24 | getters: { 25 | authenticated: () => Vue.prototype.$keycloak.authenticated, 26 | createLoginUrl: () => (options) => 27 | Vue.prototype.$keycloak.createLoginUrl(options), 28 | createLogoutUrl: () => (options) => 29 | Vue.prototype.$keycloak.createLogoutUrl(options), 30 | email: () => 31 | Vue.prototype.$keycloak.tokenParsed 32 | ? Vue.prototype.$keycloak.tokenParsed.email 33 | : '', 34 | fullName: () => Vue.prototype.$keycloak.fullName, 35 | hasResourceRoles: (_state, getters) => (resource, roles) => { 36 | if (!getters.authenticated) return false; 37 | if (!roles.length) return true; // No roles to check against 38 | 39 | if (getters.resourceAccess[resource]) { 40 | return hasRoles(getters.resourceAccess[resource].roles, roles); 41 | } 42 | return false; // There are roles to check, but nothing in token to check against 43 | }, 44 | identityProvider: () => 45 | Vue.prototype.$keycloak.tokenParsed.identity_provider, 46 | keycloakReady: () => Vue.prototype.$keycloak.ready, 47 | keycloakSubject: () => Vue.prototype.$keycloak.subject, 48 | moduleLoaded: () => !!Vue.prototype.$keycloak, 49 | presReqConfId: () => Vue.prototype.$config.keycloak.presReqConfId, 50 | realmAccess: () => Vue.prototype.$keycloak.tokenParsed.realm_access, 51 | redirectUri: (state) => state.redirectUri, 52 | resourceAccess: () => Vue.prototype.$keycloak.tokenParsed.resource_access, 53 | token: () => Vue.prototype.$keycloak.token, 54 | tokenParsed: () => Vue.prototype.$keycloak.tokenParsed, 55 | userName: () => Vue.prototype.$keycloak.userName, 56 | }, 57 | mutations: { 58 | SET_REDIRECTURI(state, redirectUri) { 59 | state.redirectUri = redirectUri; 60 | }, 61 | }, 62 | actions: { 63 | login({ commit, getters }, idpHint = undefined) { 64 | if (getters.keycloakReady) { 65 | // Use existing redirect uri if available 66 | if (!getters.redirectUri) 67 | commit('SET_REDIRECTURI', location.toString()); 68 | 69 | const options = { 70 | redirectUri: getters.redirectUri, 71 | }; 72 | 73 | // Determine idpHint based on input 74 | if (idpHint && typeof idpHint === 'string') options.idpHint = idpHint; 75 | 76 | // Redirect to Keycloak 77 | window.location.replace( 78 | getters.createLoginUrl(options) + 79 | '&pres_req_conf_id=' + 80 | getters.presReqConfId 81 | ); 82 | } 83 | }, 84 | logout({ getters }) { 85 | if (getters.keycloakReady) { 86 | window.location.replace( 87 | getters.createLogoutUrl({ 88 | redirectUri: `${location.origin}/${Vue.prototype.$config.basePath}`, 89 | }) 90 | ); 91 | } 92 | }, 93 | }, 94 | }; 95 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const ApiRoutes = Object.freeze({ 2 | HELLO: '/hello' 3 | }); 4 | 5 | export const AppRoles = Object.freeze({ 6 | TESTROLE: 'testrole' 7 | }); 8 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 58 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/views/NotFound.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 18 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/src/views/Secure.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 55 | -------------------------------------------------------------------------------- /demo/vue/app/frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | process.env.VUE_APP_VERSION = require('./package.json').version; 2 | 3 | const proxyObject = { 4 | target: 'http://localhost:8080', 5 | ws: true, 6 | changeOrigin: true 7 | }; 8 | 9 | module.exports = { 10 | publicPath: process.env.FRONTEND_BASEPATH ? process.env.FRONTEND_BASEPATH : '/app', 11 | 'transpileDependencies': [ 12 | 'vuetify' 13 | ], 14 | devServer: { 15 | proxy: { 16 | '/api': proxyObject, 17 | '/config': proxyObject 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /demo/vue/app/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | collectCoverage: true, 4 | collectCoverageFrom: ['src/**/*.js', '!frontend/**/*.*'], 5 | moduleFileExtensions: ['js', 'json'], 6 | moduleNameMapper: { 7 | '^@/(.*)$': '/src/$1' 8 | }, 9 | testMatch: [ 10 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 11 | ], 12 | testPathIgnorePatterns: ['frontend'], 13 | testURL: 'http://localhost/', 14 | }; 15 | -------------------------------------------------------------------------------- /demo/vue/app/lcov-fix.js: -------------------------------------------------------------------------------- 1 | // Jest 25.x onwards emits coverage reports on a different source path 2 | // https://stackoverflow.com/q/60323177 3 | const fs = require('fs'); 4 | const file = './coverage/lcov.info'; 5 | 6 | fs.readFile(file, 'utf8', (err, data) => { 7 | if (err) { 8 | return console.error(err); // eslint-disable-line no-console 9 | } 10 | const result = data.replace(/src/g, `${process.cwd()}/src`); 11 | 12 | fs.writeFile(file, result, 'utf8', err => { 13 | if (err) return console.error(err); // eslint-disable-line no-console 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /demo/vue/app/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "frontend/node_modules", 4 | "frontend/src", 5 | "frontend/tests", 6 | "node_modules/**/node_modules", 7 | "tests" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /demo/vue/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-scaffold-app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "serve": "nodemon ./bin/www", 8 | "build": "cd frontend && npm run build", 9 | "start": "node ./bin/www", 10 | "test": "jest --verbose --forceExit --detectOpenHandles", 11 | "lint": "eslint . **/www --no-fix --ext .js", 12 | "lint:fix": "eslint . **/www --fix --ext .js", 13 | "pretest": "npm run lint", 14 | "posttest": "node ./lcov-fix.js", 15 | "clean": "rm -rf coverage dist", 16 | "purge": "rm -rf node_modules", 17 | "rebuild": "npm run clean && npm run build", 18 | "reinstall": "npm run purge && npm install", 19 | "all:build": "npm run build", 20 | "all:start": "npm run start", 21 | "all:test": "npm run test && cd frontend && npm run test", 22 | "all:lint": "npm run lint && cd frontend && npm run lint", 23 | "all:lint-fix": "npm run lint:fix && cd frontend && npm run lint:fix", 24 | "all:ci": "npm ci && cd frontend && npm ci", 25 | "all:install": "npm install && cd frontend && npm install", 26 | "all:clean": "npm run clean && cd frontend && npm run clean", 27 | "all:purge": "npm run purge && cd frontend && npm run purge", 28 | "all:rebuild": "npm run rebuild && cd frontend && npm run rebuild", 29 | "all:reinstall": "npm run reinstall && cd frontend && npm run reinstall", 30 | "all:fresh-start": "npm run all:reinstall && npm run all:rebuild && npm run all:start", 31 | "frontend:purge": "cd frontend && npm run purge" 32 | }, 33 | "dependencies": { 34 | "api-problem": "^9.0.2", 35 | "axios": "^1.9.0", 36 | "axios-oauth-client": "^2.2.0", 37 | "axios-token-interceptor": "^0.2.0", 38 | "compression": "^1.8.0", 39 | "config": "^3.3.12", 40 | "crypto": "^1.0.1", 41 | "express": "^4.21.2", 42 | "express-winston": "^4.2.0", 43 | "form-data": "^4.0.2", 44 | "fs-extra": "^11.3.0", 45 | "js-yaml": "^4.1.0", 46 | "jsonwebtoken": "^9.0.2", 47 | "keycloak-connect": "^26.1.1", 48 | "winston": "^3.17.0", 49 | "winston-transport": "^4.9.0" 50 | }, 51 | "devDependencies": { 52 | "eslint": "^8.57.0", 53 | "eslint-config-recommended": "^4.1.0", 54 | "eslint-plugin-prettier": "^5.4.0", 55 | "jest": "^29.7.0", 56 | "nodemon": "^3.1.10", 57 | "supertest": "^7.1.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /demo/vue/app/src/components/clientConnection.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const oauth = require('axios-oauth-client'); 3 | const tokenProvider = require('axios-token-interceptor'); 4 | 5 | const log = require('./log')(module.filename); 6 | 7 | class ClientConnection { 8 | constructor({ tokenUrl, clientId, clientSecret }) { 9 | log.verbose(`Constructed with ${tokenUrl}, ${clientId}, clientSecret`, { function: 'constructor' }); 10 | if (!tokenUrl || !clientId || !clientSecret) { 11 | log.error('Invalid configuration.', { function: 'clientConnection' }); 12 | throw new Error('ClientConnection is not configured. Check configuration.'); 13 | } 14 | 15 | this.tokenUrl = tokenUrl; 16 | 17 | this.axios = axios.create(); 18 | this.axios.interceptors.request.use( 19 | // Wraps axios-token-interceptor with oauth-specific configuration, 20 | // fetches the token using the desired claim method, and caches 21 | // until the token expires 22 | oauth.interceptor(tokenProvider, oauth.client(axios.create(), { 23 | url: this.tokenUrl, 24 | grant_type: 'client_credentials', 25 | client_id: clientId, 26 | client_secret: clientSecret, 27 | scope: '' 28 | })) 29 | ); 30 | } 31 | } 32 | 33 | module.exports = ClientConnection; 34 | -------------------------------------------------------------------------------- /demo/vue/app/src/components/errorToProblem.js: -------------------------------------------------------------------------------- 1 | const Problem = require('api-problem'); 2 | 3 | const log = require('./log')(module.filename); 4 | 5 | module.exports = function(service, e) { 6 | if (e.response) { 7 | // Handle raw data 8 | let data; 9 | if (typeof e.response.data === 'string' || e.response.data instanceof String) { 10 | data = JSON.parse(e.response.data); 11 | } else { 12 | data = e.response.data; 13 | } 14 | 15 | log.error(`Error from ${service}: status = ${e.response.status}, data : ${JSON.stringify(data)}`); 16 | // Validation Error 17 | if (e.response.status === 422) { 18 | throw new Problem(e.response.status, { 19 | detail: data.detail, 20 | errors: data.errors 21 | }); 22 | } 23 | // Something else happened but there's a response 24 | throw new Problem(e.response.status, { detail: e.response.data.toString() }); 25 | } else { 26 | log.error(`Unknown error calling ${service}: ${e.message}`); 27 | throw new Problem(502, `Unknown ${service} Error`, { detail: e.message }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /demo/vue/app/src/components/hello.js: -------------------------------------------------------------------------------- 1 | const log = require('./log')(module.filename); 2 | 3 | const hello = { 4 | /** 5 | * @function getHello 6 | * Returns hello world 7 | * @returns {string} A string 8 | */ 9 | getHello: () => { 10 | const value = 'Hello World!'; 11 | log.info(value, { function: 'getHello' }); 12 | return value; 13 | } 14 | }; 15 | 16 | module.exports = hello; 17 | -------------------------------------------------------------------------------- /demo/vue/app/src/components/keycloak.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const Keycloak = require('keycloak-connect'); 3 | 4 | module.exports = new Keycloak( 5 | {}, 6 | { 7 | bearerOnly: true, 8 | 'confidential-port': 0, 9 | clientId: config.get('server.keycloak.clientId'), 10 | 'policy-enforcer': {}, 11 | realm: config.get('server.keycloak.realm'), 12 | realmPublicKey: config.has('server.keycloak.publicKey') 13 | ? config.get('server.keycloak.publicKey') 14 | : undefined, 15 | secret: config.get('server.keycloak.clientSecret'), 16 | serverUrl: config.get('server.keycloak.serverUrl'), 17 | 'ssl-required': 'external', 18 | 'use-resource-role-mappings': true, 19 | 'verify-token-audience': false, 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /demo/vue/app/src/components/log.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const jwt = require('jsonwebtoken'); 3 | const { parse } = require('path'); 4 | const Transport = require('winston-transport'); 5 | const { createLogger, format, transports } = require('winston'); 6 | const { logger } = require('express-winston'); 7 | 8 | /** 9 | * Class representing a winston transport writing to null 10 | * @extends Transport 11 | */ 12 | class NullTransport extends Transport { 13 | /** 14 | * Constructor 15 | * @param {object} opts Winston Transport options 16 | */ 17 | constructor(opts) { 18 | super(opts); 19 | } 20 | 21 | /** 22 | * The transport logger 23 | * @param {object} _info Object to log 24 | * @param {function} callback Callback function 25 | */ 26 | log(_info, callback) { 27 | callback(); 28 | } 29 | } 30 | 31 | /** 32 | * Main Winston Logger 33 | * @returns {object} Winston Logger 34 | */ 35 | const log = createLogger({ 36 | exitOnError: false, 37 | format: format.combine( 38 | format.errors({ stack: true }), // Force errors to show stacktrace 39 | format.timestamp(), // Add ISO timestamp to each entry 40 | format.json(), // Force output to be in JSON format 41 | ), 42 | level: config.get('server.logLevel') 43 | }); 44 | 45 | if (process.env.NODE_ENV !== 'test') { 46 | log.add(new transports.Console({ handleExceptions: true })); 47 | } else { 48 | log.add(new NullTransport()); 49 | } 50 | 51 | if (config.has('server.logFile')) { 52 | log.add(new transports.File({ 53 | filename: config.get('server.logFile'), 54 | handleExceptions: true 55 | })); 56 | } 57 | 58 | /** 59 | * Returns a Winston Logger or Child Winston Logger 60 | * @param {string} [filename] Optional module filename path to annotate logs with 61 | * @returns {object} A child logger with appropriate metadata if `filename` is defined. Otherwise returns a standard logger. 62 | */ 63 | const getLogger = (filename) => { 64 | return filename ? log.child({ component: parse(filename).name }) : log; 65 | }; 66 | 67 | /** 68 | * Returns an express-winston middleware function for http logging 69 | * @returns {function} An express-winston middleware function 70 | */ 71 | const httpLogger = logger({ 72 | colorize: false, 73 | // Parses express information to insert into log output 74 | dynamicMeta: (req, res) => { 75 | const token = jwt.decode((req.get('authorization') || '').slice(7)); 76 | return { 77 | azp: token && token.azp || undefined, 78 | contentLength: res.get('content-length'), 79 | httpVersion: req.httpVersion, 80 | ip: req.ip, 81 | method: req.method, 82 | path: req.path, 83 | query: Object.keys(req.query).length ? req.query : undefined, 84 | responseTime: res.responseTime, 85 | statusCode: res.statusCode, 86 | userAgent: req.get('user-agent') 87 | }; 88 | }, 89 | expressFormat: true, // Use express style message strings 90 | level: 'http', 91 | meta: true, // Must be true for dynamicMeta to execute 92 | metaField: null, // Set to null for all attributes to be at top level object 93 | requestWhitelist: [], // Suppress default value output 94 | responseWhitelist: [], // Suppress default value output 95 | // Skip logging kube-probe requests 96 | skip: (req) => req.get('user-agent') && req.get('user-agent').includes('kube-probe'), 97 | winstonInstance: log, 98 | }); 99 | 100 | module.exports = getLogger; 101 | module.exports.httpLogger = httpLogger; 102 | module.exports.NullTransport = NullTransport; 103 | -------------------------------------------------------------------------------- /demo/vue/app/src/docs/docs.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | 3 | const docs = { 4 | getDocHTML: version => ` 5 | 6 | 7 | Vue Skeleton API - Documentation ${version} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | ` 23 | }; 24 | 25 | module.exports = docs; 26 | -------------------------------------------------------------------------------- /demo/vue/app/src/routes/v1.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const router = require('express').Router(); 5 | const yaml = require('js-yaml'); 6 | 7 | const keycloak = require('../components/keycloak'); 8 | const helloRouter = require('./v1/hello'); 9 | 10 | const getSpec = () => { 11 | const rawSpec = fs.readFileSync(path.join(__dirname, '../docs/v1.api-spec.yaml'), 'utf8'); 12 | const spec = yaml.load(rawSpec); 13 | spec.servers[0].url = `${config.get('server.basePath')}/api/v1`; 14 | spec.components.securitySchemes.OpenID.openIdConnectUrl = `${config.get('server.keycloak.serverUrl')}/realms/${config.get('server.keycloak.realm')}/.well-known/openid-configuration`; 15 | return spec; 16 | }; 17 | 18 | // Base v1 Responder 19 | router.get('/', (_req, res) => { 20 | res.status(200).json({ 21 | endpoints: [ 22 | '/docs', 23 | '/hello' 24 | ] 25 | }); 26 | }); 27 | 28 | /** OpenAPI Docs */ 29 | router.get('/docs', (_req, res) => { 30 | const docs = require('../docs/docs'); 31 | res.send(docs.getDocHTML('v1')); 32 | }); 33 | 34 | /** OpenAPI YAML Spec */ 35 | router.get('/api-spec.yaml', (_req, res) => { 36 | res.status(200).type('application/yaml').send(yaml.dump(getSpec())); 37 | }); 38 | 39 | /** OpenAPI JSON Spec */ 40 | router.get('/api-spec.json', (_req, res) => { 41 | res.status(200).json(getSpec()); 42 | }); 43 | 44 | /** Hello Router */ 45 | router.use('/hello', keycloak.protect(), helloRouter); 46 | 47 | module.exports = router; 48 | -------------------------------------------------------------------------------- /demo/vue/app/src/routes/v1/hello.js: -------------------------------------------------------------------------------- 1 | const helloRouter = require('express').Router(); 2 | 3 | const helloComponent = require('../../components/hello'); 4 | 5 | /** Returns hello world result */ 6 | // eslint-disable-next-line no-unused-vars 7 | helloRouter.get('/', (_req, res, _next) => { 8 | const result = helloComponent.getHello(); 9 | res.status(200).json(result); 10 | }); 11 | 12 | module.exports = helloRouter; 13 | -------------------------------------------------------------------------------- /demo/vue/app/tests/common/helper.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const Problem = require('api-problem'); 3 | 4 | /** 5 | * @class helper 6 | * Provides helper utilities that are commonly used in tests 7 | */ 8 | const helper = { 9 | /** 10 | * @function expressHelper 11 | * Creates a stripped-down simple Express server object 12 | * @param {string} basePath The path to mount the `router` on 13 | * @param {object} router An express router object to mount 14 | * @returns {object} A simple express server object with `router` mounted to `basePath` 15 | */ 16 | expressHelper: (basePath, router) => { 17 | const app = express(); 18 | 19 | app.use(express.json()); 20 | app.use(express.urlencoded({ 21 | extended: false 22 | })); 23 | app.use(basePath, router); 24 | 25 | // Handle 500 26 | // eslint-disable-next-line no-unused-vars 27 | app.use((err, _req, res, _next) => { 28 | if (err instanceof Problem) { 29 | err.send(res); 30 | } else { 31 | new Problem(500, { 32 | details: (err.message) ? err.message : err 33 | }).send(res); 34 | } 35 | }); 36 | 37 | // Handle 404 38 | app.use((_req, res) => { 39 | new Problem(404).send(res); 40 | }); 41 | 42 | return app; 43 | } 44 | }; 45 | 46 | module.exports = helper; 47 | -------------------------------------------------------------------------------- /demo/vue/app/tests/unit/components/errorToProblem.spec.js: -------------------------------------------------------------------------------- 1 | const errorToProblem = require('../../../src/components/errorToProblem'); 2 | 3 | const SERVICE = 'TESTSERVICE'; 4 | 5 | describe('errorToProblem', () => { 6 | it('should throw a 422', () => { 7 | const e = { 8 | response: { 9 | data: { detail: 'detail' }, 10 | status: 422 11 | } 12 | }; 13 | expect(() => errorToProblem(SERVICE, e)).toThrow('422'); 14 | }); 15 | 16 | it('should throw a 502', () => { 17 | const e = { 18 | message: 'msg' 19 | }; 20 | expect(() => errorToProblem(SERVICE, e)).toThrow('502'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /demo/vue/app/tests/unit/components/hello.spec.js: -------------------------------------------------------------------------------- 1 | const hello = require('../../../src/components/hello'); 2 | 3 | describe('getHello', () => { 4 | it('should return a string', () => { 5 | const result = hello.getHello(); 6 | 7 | expect(result).toBeTruthy(); 8 | expect(result).toMatch('Hello World!'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demo/vue/app/tests/unit/components/log.spec.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | 3 | const getLogger = require('../../../src/components/log'); 4 | const httpLogger = require('../../../src/components/log').httpLogger; 5 | 6 | describe('getLogger', () => { 7 | const assertLogger = (log) => { 8 | expect(log).toBeTruthy(); 9 | expect(typeof log).toBe('object'); 10 | expect(typeof log.pipe).toBe('function'); 11 | expect(log.exitOnError).toBeFalsy(); 12 | expect(log.format).toBeTruthy(); 13 | expect(log.level).toBe(config.get('server.logLevel')); 14 | expect(log.transports).toHaveLength(1); 15 | }; 16 | 17 | it('should return a winston logger', () => { 18 | const result = getLogger(); 19 | assertLogger(result); 20 | }); 21 | 22 | it('should return a child winston logger with metadata overrides', () => { 23 | const result = getLogger('test'); 24 | assertLogger(result); 25 | }); 26 | }); 27 | 28 | describe('httpLogger', () => { 29 | it('should return a winston middleware function', () => { 30 | const result = httpLogger; 31 | 32 | expect(result).toBeTruthy(); 33 | expect(typeof result).toBe('function'); 34 | expect(result.length).toBe(3); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /demo/vue/app/tests/unit/routes/v1.spec.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | 3 | const { expressHelper } = require('../../common/helper'); 4 | const router = require('../../../src/routes/v1'); 5 | 6 | // Simple Express Server 7 | const basePath = '/api/v1'; 8 | const app = expressHelper(basePath, router); 9 | 10 | describe(`GET ${basePath}`, () => { 11 | it('should return all available endpoints', async () => { 12 | const response = await request(app).get(`${basePath}`); 13 | 14 | expect(response.statusCode).toBe(200); 15 | expect(response.body).toBeTruthy(); 16 | expect(Array.isArray(response.body.endpoints)).toBeTruthy(); 17 | expect(response.body.endpoints).toHaveLength(2); 18 | expect(response.body.endpoints).toContain('/docs'); 19 | expect(response.body.endpoints).toContain('/hello'); 20 | }); 21 | }); 22 | 23 | describe(`GET ${basePath}/docs`, () => { 24 | it('should return a redoc html page', async () => { 25 | const response = await request(app).get(`${basePath}/docs`); 26 | 27 | expect(response.statusCode).toBe(200); 28 | expect(response.text).toContain('Vue Skeleton API - Documentation'); 29 | }); 30 | }); 31 | 32 | describe(`GET ${basePath}/api-spec.yaml`, () => { 33 | it('should return the OpenAPI spec in yaml', async () => { 34 | const response = await request(app).get(`${basePath}/api-spec.yaml`); 35 | 36 | expect(response.statusCode).toBe(200); 37 | expect(response.body).toBeTruthy(); 38 | expect(response.headers['content-type']).toBeTruthy(); 39 | expect(response.headers['content-type']).toMatch('application/yaml; charset=utf-8'); 40 | expect(response.text).toContain('openapi: 3.0.3'); 41 | expect(response.text).toContain('title: Vue Skeleton API'); 42 | }); 43 | }); 44 | 45 | describe(`GET ${basePath}/api-spec.json`, () => { 46 | it('should return the OpenAPI spec in json', async () => { 47 | const response = await request(app).get(`${basePath}/api-spec.json`); 48 | 49 | expect(response.statusCode).toBe(200); 50 | expect(response.headers['content-type']).toBeTruthy(); 51 | expect(response.headers['content-type']).toMatch('application/json; charset=utf-8'); 52 | expect(response.body).toBeTruthy(); 53 | expect(response.body.openapi).toMatch('3.0.3'); 54 | expect(response.body.info.title).toMatch('Vue Skeleton API'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /demo/vue/app/tests/unit/routes/v1/hello.spec.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | 3 | const { expressHelper } = require('../../../common/helper'); 4 | const router = require('../../../../src/routes/v1/hello'); 5 | 6 | const helloComponent = require('../../../../src/components/hello'); 7 | 8 | // Simple Express Server 9 | const basePath = '/api/v1/hello'; 10 | const app = expressHelper(basePath, router); 11 | 12 | describe(`GET ${basePath}`, () => { 13 | const getHelloSpy = jest.spyOn(helloComponent, 'getHello'); 14 | 15 | afterEach(() => { 16 | getHelloSpy.mockReset(); 17 | }); 18 | 19 | it('should yield a created response', async () => { 20 | getHelloSpy.mockReturnValue('test'); 21 | 22 | const response = await request(app).get(`${basePath}`); 23 | 24 | expect(response.statusCode).toBe(200); 25 | expect(response.body).toBeTruthy(); 26 | expect(response.body).toMatch('test'); 27 | expect(getHelloSpy).toHaveBeenCalledTimes(1); 28 | }); 29 | 30 | it('should yield an error and fail gracefully', async () => { 31 | getHelloSpy.mockImplementation(() => { 32 | throw new Error('bad'); 33 | }); 34 | 35 | const response = await request(app).get(`${basePath}`); 36 | 37 | expect(response.statusCode).toBe(500); 38 | expect(response.body).toBeTruthy(); 39 | expect(response.body.details).toBe('bad'); 40 | expect(getHelloSpy).toHaveBeenCalledTimes(1); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /demo/vue/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | vue-app: 3 | ports: 4 | - "${IP:-0.0.0.0}:8080:8080" 5 | build: . 6 | command: npm run serve 7 | environment: 8 | FRONTEND_KC_PRES_REQ_CONF_ID: test-proof 9 | FRONTEND_KC_SERVERURL: "http://localhost:8880/auth" 10 | FRONTEND_KC_REALM: "vc-authn" 11 | FRONTEND_KC_CLIENTID: "vue-fe" 12 | SERVER_KC_SERVERURL: "http://localhost:8880/auth" 13 | SERVER_KC_REALM: "vc-authn" 14 | SERVER_KC_CLIENTSECRET: "r7Sc7iw2deFz3olSXlFiVoaaODIZf3vp" 15 | SERVER_KC_CLIENTID: "vue-be" 16 | -------------------------------------------------------------------------------- /demo/vue/vetur.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('vls').VeturConfig} */ 2 | module.exports = { 3 | settings: { 4 | "vetur.useWorkspaceDependencies": true, 5 | "vetur.experimental.templateInterpolationService": true 6 | }, 7 | projects: [ 8 | './app/frontend' 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /docker/agent/config/ledgers.yaml: -------------------------------------------------------------------------------- 1 | - id: BCovrinTest 2 | is_production: true 3 | is_write: true 4 | genesis_url: "http://test.bcovrin.vonx.io/genesis" 5 | # - id: SovrinStagingNet 6 | # is_production: true 7 | # genesis_url: 'https://raw.githubusercontent.com/sovrin-foundation/sovrin/stable/sovrin/pool_transactions_sandbox_genesis' 8 | - id: CANdyDev 9 | is_production: true 10 | genesis_url: "https://raw.githubusercontent.com/ICCS-ISAC/dtrust-reconu/main/CANdy/dev/pool_transactions_genesis" 11 | - id: CANdyTest 12 | is_production: true 13 | genesis_url: "https://raw.githubusercontent.com/ICCS-ISAC/dtrust-reconu/main/CANdy/test/pool_transactions_genesis" 14 | - id: CANdyProd 15 | is_production: true 16 | genesis_url: "https://raw.githubusercontent.com/ICCS-ISAC/dtrust-reconu/main/CANdy/prod/pool_transactions_genesis" 17 | -------------------------------------------------------------------------------- /docker/docker-compose-ngrok.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | ngrok: 3 | image: ngrok/ngrok 4 | environment: 5 | - NGROK_AUTHTOKEN=${NGROK_AUTHTOKEN} 6 | ports: 7 | - 4046:4040 8 | command: 9 | - "start" 10 | - "--all" 11 | - "--config" 12 | - "/etc/ngrok.yml" 13 | volumes: 14 | - ./ngrok.yml:/etc/ngrok.yml 15 | networks: 16 | - vc_auth 17 | 18 | networks: 19 | vc_auth: 20 | driver: bridge 21 | -------------------------------------------------------------------------------- /docker/mongo/mongo-init.js: -------------------------------------------------------------------------------- 1 | db.createUser({ 2 | user: "oidccontrolleruser", 3 | pwd: "oidccontrollerpass", 4 | roles: [ 5 | { 6 | role: "readWrite", 7 | db: "oidccontroller", 8 | }, 9 | ], 10 | }); 11 | -------------------------------------------------------------------------------- /docker/ngrok.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | tunnels: 3 | controller-ngrok: 4 | addr: controller:5000 5 | proto: http 6 | schemes: 7 | - https 8 | aca-py-ngrok: 9 | addr: aca-py:8030 10 | proto: http 11 | schemes: 12 | - https 13 | log: stdout -------------------------------------------------------------------------------- /docker/oidc-controller/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 AS main 2 | 3 | WORKDIR /app/src 4 | 5 | ENV POETRY_VIRTUALENVS_CREATE=false 6 | RUN pip3 install --no-cache-dir poetry==2.0 7 | 8 | COPY pyproject.toml poetry.lock README.md ./ 9 | RUN poetry install --no-root --only main 10 | 11 | COPY ./oidc-controller . 12 | COPY ./html-templates /app/controller-config/templates 13 | 14 | EXPOSE 5000 15 | 16 | RUN ["chmod", "+x", "./docker-entrypoint.sh"] 17 | 18 | ENTRYPOINT ["./docker-entrypoint.sh"] 19 | -------------------------------------------------------------------------------- /docker/oidc-controller/config/sessiontimeout.json: -------------------------------------------------------------------------------- 1 | ["expired", "failed", "abandoned"] 2 | -------------------------------------------------------------------------------- /docker/oidc-controller/config/user_variable_substitution.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/docker/oidc-controller/config/user_variable_substitution.py -------------------------------------------------------------------------------- /docker/oidc-controller/config/user_variable_substitution_example.py: -------------------------------------------------------------------------------- 1 | def sub_days_plus_one(days: str) -> int: 2 | """Strings like '$sub_days_plus_one_4' will be replaced with the 3 | final number incremented by one. In this case 5. 4 | $sub_days_plus_one_4 -> 5 5 | $sub_days_plus_one_10 -> 11""" 6 | return int(days) + 1 7 | 8 | 9 | variable_substitution_map.add_variable_substitution( 10 | r"\$sub_days_plus_one_(\d+)", sub_days_plus_one 11 | ) 12 | 13 | 14 | def sub_string_for_sure(_: str) -> str: 15 | """Turns strings like $sub_string_for_sure_something into the string 'sure' 16 | $sub_string_for_sure_something -> sure 17 | $sub_string_for_sure_i -> sure 18 | """ 19 | return "sure" 20 | 21 | 22 | variable_substitution_map.add_variable_substitution( 23 | r"\$sub_string_for_sure_(.+)", sub_string_for_sure 24 | ) 25 | -------------------------------------------------------------------------------- /docs/BestPractices.md: -------------------------------------------------------------------------------- 1 | # VC AuthN Best Practices 2 | 3 | This document is intended as a list of best practices and recommendations that are applicable when using `acapy-vc-authn-oidc` as means of authorization provider for web applications. 4 | 5 | ## Ensure the response for the right proof was received 6 | 7 | When using `acapy-vc-authn-oidc` to secure a web application, the request to the identity provider must include a `pres_req_conf_id` query parameter set to the id of the `acapy-vc-authn-oidc` configuration that must be used to authenticate with the Identity Provider. 8 | 9 | The query parameter - however - can be changed dynamically: this is a desired behaviour, as it allows web applications to dynamically request the proof-request for the circumstance/scenario that is more appropriate. 10 | 11 | Similarly to checking a user's roles, when an id token is received from `acapy-vc-authn-oidc` the application should check that the value of the `pres_req_conf_id` attribute on the id token matches the value of the query parameter submitted to the IdP in the first place. If this is not the case, the user authentication may have been successful, but it did not satisfy the initial requirements (another example could be a web application that allows authentication using multiple Identity Providers, but only one of those is authorized to provide extended privileges to the user). 12 | 13 | ## Consume the claims produced by the proof-request 14 | 15 | Starting with `v2.0.0`, claims populated by values provided in a proof-request are no longer individually added to the root of the JWT issued by ACAPy VC-AuthN. This decision was made to limit the number of attribute mappers required in systems with more than one proof-request configuration available for relying parties to consume when authenticating. 16 | 17 | Instead, all claims are packaged in the `vc_presented_attributes` returned at the root of the JWT. Example: 18 | 19 | ```json 20 | { 21 | ... 22 | "vc_presented_attributes": { 23 | "email": "test@email.com", 24 | "given_name": "Some", 25 | "family_name": "One" 26 | }, 27 | ... 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/MigrationGuide.md: -------------------------------------------------------------------------------- 1 | # ACAPy VC-AuthN Migration Guide 2 | 3 | This document contains instructions and tips useful when upgrading ACAPy VC-AuthN. 4 | 5 | ## 1.x -> 2.x 6 | 7 | The functionality has mostly remained unchanged, however there are some details that need to be accounted for. 8 | 9 | * Endpoints: `authorization` and `token` endpoints have changed, review the new values by navigating to the `.well-known` URL and update your integration accordingly. 10 | 11 | * Proof configurations: to be moved to a `v2.0` instance, the following changes need to happen in existing proof-configurations. 12 | - The `name` identifier for disclosed attributes has been deprecated, use the `names` array instead. 13 | - If backwards-compatibility with `v1.0` tokens is required, the `include_v1_attributes` flag should be switched to `true` (see the [configuration guide](./ConfigurationGuide.md)). 14 | 15 | * Client Types: ACAPy VC-AuthN 2.0 currently only supports confidential clients using client id/secret. If public clients were previously registered, they will now need to use an AIM (e.g.: keycloak) as broker. 16 | -------------------------------------------------------------------------------- /docs/img/01-new-idp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/docs/img/01-new-idp.png -------------------------------------------------------------------------------- /docs/img/02-settings-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/docs/img/02-settings-1.png -------------------------------------------------------------------------------- /docs/img/02-settings-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/docs/img/02-settings-2.png -------------------------------------------------------------------------------- /docs/img/03-mappers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/docs/img/03-mappers.png -------------------------------------------------------------------------------- /docs/img/vc-authn-oidc-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/docs/img/vc-authn-oidc-flow.png -------------------------------------------------------------------------------- /docs/vc-authn-oidc-flow.puml: -------------------------------------------------------------------------------- 1 | @startuml "vc-authn-oidc-flow" 2 | actor User 3 | entity UserAgent 4 | entity RP 5 | entity OP 6 | entity IdentityWallet 7 | 8 | User -> UserAgent: Naviagates to RP website 9 | UserAgent -> RP: Fetch RP website 10 | RP --> UserAgent: Return website 11 | User -> UserAgent: Click "vc-authn challenge" button (e.g verify your age) 12 | 13 | group Challenge Method 14 | alt UserAgent Generated Challenge 15 | UserAgent -> UserAgent: Generate OIDC VC-Authn request 16 | UserAgent -> OP: Redirect to OIDC VC-Authn request 17 | end 18 | 19 | alt RP Generated Challenge 20 | UserAgent -> RP: Create OIDC VC-Authn request 21 | RP --> UserAgent: Redirect to OIDC VC-Authn request 22 | UserAgent -> OP: Follow OIDC VC-Authn request 23 | end 24 | end 25 | 26 | OP -> OP: Generate VC Presentation Request 27 | OP -> UserAgent: Return VC Presentation Request 28 | 29 | group IdentityWallet Communication Method 30 | alt QR Code Scanned By IdentityWallet 31 | loop UserAgent await IdentityWallet response 32 | UserAgent -> OP: Request challenge status 33 | OP --> UserAgent: "authentication_pending" response 34 | end 35 | note left: The UserAgent polls the OP until the IdentityWallet responds to the request 36 | UserAgent -> IdentityWallet: Request Scanned 37 | IdentityWallet -> IdentityWallet: Request validated 38 | IdentityWallet -> User : Present verify data request 39 | User -> IdentityWallet : Clicks accept request 40 | IdentityWallet -> IdentityWallet : Generate VC presentation 41 | IdentityWallet -> OP : Submit VC presentation 42 | UserAgent -> OP: Request challenge status 43 | end 44 | 45 | alt Deeplink invoked by IdentityWallet 46 | UserAgent -> IdentityWallet: Deeplink invoked 47 | IdentityWallet -> IdentityWallet: Request validated 48 | IdentityWallet -> User : Present verify data request 49 | User -> IdentityWallet : Clicks accept request 50 | IdentityWallet -> IdentityWallet : Generate VC presentation 51 | IdentityWallet -> OP : Submit VC presentation 52 | IdentityWallet -> UserAgent: Redirect to OP URI 53 | UserAgent -> OP: Follow redirect 54 | end 55 | end 56 | 57 | group OAuth Flow 58 | alt Authorization Code Flow 59 | OP -> UserAgent: Redirect request to RP redirect URI with authorization code 60 | UserAgent -> RP: Follow redirect with authorization code 61 | RP -> OP: Request token with authorization code 62 | OP --> RP: Token response 63 | end 64 | 65 | alt Implicit Flow 66 | OP -> UserAgent: Redirect request to RP redirect URI with token 67 | UserAgent -> RP: Follow redirect with token 68 | end 69 | end 70 | 71 | RP --> UserAgent: Success web page 72 | UserAgent --> User: ACK 73 | 74 | @enduml 75 | -------------------------------------------------------------------------------- /html-templates/assets/css/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --alert-link-text: #1a5a96; 3 | --bc-primary: #003366; 4 | --bc-secondary: #335c85; 5 | --bc-accent: #fcba19; 6 | --bc-btn-disabled-color: #b2c1d1; 7 | --bc-btn-focus-color: #3b99fc; 8 | --font-color: #313132; 9 | --grid-container-max-width: 480px; 10 | --grid-qr-width: 324px; 11 | --qr-accent-color: #0048c4; 12 | } 13 | body { 14 | font-family: "Arial", sans-serif; 15 | color: var(--font-color); 16 | } 17 | main.container { 18 | max-width: var(--grid-container-max-width); 19 | padding: 0 15px; 20 | } 21 | h1 { 22 | font-size: 2.25rem; 23 | font-weight: 700; 24 | } 25 | .custom-column { 26 | max-width: var(--grid-qr-width); 27 | } 28 | .alert { 29 | color: black; 30 | } 31 | a { 32 | color: var(--alert-link-text); 33 | } 34 | .status-icon img { 35 | height: 3rem; 36 | width: 3rem; 37 | } 38 | @keyframes spin { 39 | to { 40 | transform: rotate(360deg); 41 | } 42 | } 43 | .icon-rotate { 44 | animation: spin 2s linear infinite; 45 | } 46 | .qr-code-container { 47 | position: relative; 48 | } 49 | .qr-code-img-border { 50 | margin-left: -5px; /* offset to avoid shifting qr right with boarder */ 51 | display: inline-block; 52 | border-radius: 25px; 53 | border: 5px dashed var(--bc-primary); 54 | } 55 | .qr-code-container .qr-code-img { 56 | border-radius: 25px; 57 | } 58 | .qr-fade { 59 | opacity: 0.1; 60 | } 61 | .qr-button { 62 | position: absolute; 63 | z-index: 10; /* Ensure it is on top of the image */ 64 | height: 300px; 65 | width: 300px; 66 | background-color: rgba(255, 255, 255, 0); 67 | display: flex; 68 | flex-direction: column; 69 | align-items: center; 70 | justify-content: center; 71 | border-style: none; 72 | cursor: pointer; 73 | } 74 | .qr-button .btn { 75 | border-radius: 4px; 76 | border-width: 2px; 77 | border-color: var(--qr-accent-color); 78 | color: var(--qr-accent-color); 79 | } 80 | .qr-button .btn:hover, 81 | .qr-button .btn:active { 82 | background-color: #f8f9fa; 83 | border-color: var(--bc-primary); 84 | color: var(--bc-primary); 85 | } 86 | /* BC Gov Styles */ 87 | .btn { 88 | --bs-btn-hover-bg: var(--bc-secondary); 89 | --bs-btn-active-bg: var(--bc-secondary); 90 | --bs-btn-hover-border-color: var(--bc-secondary); 91 | } 92 | .btn-primary { 93 | border-color: var(--bc-primary); 94 | background-color: var(--bc-primary); 95 | } 96 | .btn-primary.disabled { 97 | --bs-btn-disabled-bg: var(--bc-btn-disabled-color); 98 | --bs-btn-disabled-border-color: var(--bc-btn-disabled-color); 99 | } 100 | .btn-primary:focus, 101 | .btn-block-secondary:focus { 102 | outline-offset: 1px; 103 | outline: 4px solid var(--bc-btn-focus-color); 104 | } 105 | .btn-block-secondary { 106 | color: var(--bc-primary); 107 | border-color: var(--bc-primary); 108 | } 109 | .btn-block-secondary.disabled { 110 | opacity: 0.3; 111 | color: var(--bc-primary); 112 | border-color: var(--bc-primary); 113 | } 114 | .navbar { 115 | background-color: var(--bc-primary); 116 | border-bottom: 4px solid var(--bc-accent); 117 | box-shadow: 0px 3px 3px 0px #dedede; 118 | padding-left: 1em; 119 | } 120 | .navbar img { 121 | height: 4rem; 122 | } 123 | .back-btn { 124 | display: block; 125 | margin-bottom: 1em; 126 | font-weight: bold; 127 | text-decoration: none; 128 | } 129 | .desktop-head .back-btn { 130 | margin-left: -350px; 131 | } 132 | footer { 133 | background-color: var(--bc-primary); 134 | border-top: 2px solid var(--bc-accent); 135 | color: #fff; 136 | } 137 | footer ul { 138 | display: flex; 139 | flex-wrap: wrap; 140 | margin: 0; 141 | color: #fff; 142 | list-style: none; 143 | } 144 | footer ul li a { 145 | font-size: 0.813em; 146 | color: #fff; 147 | padding: 0 1em; 148 | text-decoration: none; 149 | } 150 | footer ul li a:hover { 151 | text-decoration: underline; 152 | } 153 | 154 | [v-cloak] { 155 | display: none; 156 | } 157 | -------------------------------------------------------------------------------- /html-templates/assets/img/circle-check.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"> 2 | <path id="Icon_material-check-circle" data-name="Icon material-check-circle" d="M27,3A24,24,0,1,0,51,27,24.009,24.009,0,0,0,27,3ZM22.2,39l-12-12,3.384-3.384L22.2,32.208,40.416,13.992,43.8,17.4Z" transform="translate(-3 -3)" fill="#2e8540"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /html-templates/assets/img/circle-x.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"> 2 | <path id="Icon_ionic-md-close-circle" data-name="Icon ionic-md-close-circle" d="M27.375,3.375a24,24,0,1,0,24,24A23.917,23.917,0,0,0,27.375,3.375Zm12,32.64-3.36,3.36-8.64-8.64-8.64,8.64-3.36-3.36,8.64-8.64-8.64-8.64,3.36-3.36,8.64,8.64,8.64-8.64,3.36,3.36-8.64,8.64Z" transform="translate(-3.375 -3.375)" fill="#d8292f"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /html-templates/assets/img/dashed-border.svg: -------------------------------------------------------------------------------- 1 | <svg> 2 | <rect width="300" height="300" fill="none" x="3" y="3" rx="29" ry="29" stroke="#0048c4" stroke-width="7" stroke-dasharray="90,5.83" stroke-linecap="butt" /> 3 | </svg> 4 | -------------------------------------------------------------------------------- /html-templates/assets/img/expired.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <svg 3 | xmlns:dc="http://purl.org/dc/elements/1.1/" 4 | xmlns:cc="http://creativecommons.org/ns#" 5 | xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" 6 | xmlns:svg="http://www.w3.org/2000/svg" 7 | xmlns="http://www.w3.org/2000/svg" 8 | id="svg8" 9 | version="1.1" 10 | viewBox="0 0 31.669514 33.408138" 11 | height="33.408138mm" 12 | width="31.669514mm"> 13 | <defs 14 | id="defs2" /> 15 | <metadata 16 | id="metadata5"> 17 | <rdf:RDF> 18 | <cc:Work 19 | rdf:about=""> 20 | <dc:format>image/svg+xml</dc:format> 21 | <dc:type 22 | rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> 23 | <dc:title></dc:title> 24 | </cc:Work> 25 | </rdf:RDF> 26 | </metadata> 27 | <g 28 | transform="translate(-126.23839,-116.28128)" 29 | id="layer1"> 30 | <path 31 | id="path72" 32 | d="m 127.82508,149.07223 c -1.02877,-0.62723 -1.74574,-2.14195 -1.55627,-3.28792 0.0733,-0.44349 1.50405,-3.1108 3.17939,-5.92737 l 3.04606,-5.12102 -0.39845,-1.09668 c -0.21915,-0.60318 -0.46235,-2.04919 -0.54046,-3.21335 -0.80092,-11.93807 13.342,-18.71946 22.08856,-10.59126 5.34518,4.96729 5.71327,13.16142 0.83119,18.50319 -1.78714,1.95542 -3.50872,3.02051 -6.41329,3.96775 -0.4441,0.14482 -0.41756,0.27155 0.33073,1.57943 1.26163,2.20511 0.95025,4.10526 -0.85293,5.20472 -1.32844,0.81 -18.3821,0.79487 -19.71453,-0.0175 z m 18.7805,-2.0186 c 0.26612,-0.24084 0.48386,-0.606 0.48386,-0.81147 0,-0.20547 -1.92227,-3.58656 -4.2717,-7.51352 -4.22045,-7.05429 -4.28155,-7.13897 -5.09323,-7.05989 -0.77009,0.075 -1.08188,0.51077 -4.97967,6.95922 -4.04198,6.68701 -4.56327,7.84151 -3.85874,8.54604 0.55508,0.55508 17.09729,0.44269 17.71948,-0.12038 z m -9.68544,-1.65983 c -0.59657,-0.65919 -0.32741,-1.55171 0.53308,-1.76768 0.78761,-0.19768 1.43413,0.28322 1.43413,1.06674 0,1.18735 -1.15468,1.59878 -1.96721,0.70094 z m 0.15932,-3.273 c -0.4567,-0.55029 -0.72042,-5.90725 -0.31487,-6.3959 0.45987,-0.55411 1.4181,-0.50752 1.9638,0.0955 0.4098,0.45283 0.4234,0.76918 0.14501,3.37344 -0.16896,1.58056 -0.36103,2.96304 -0.42682,3.07218 -0.1928,0.31983 -1.05739,0.22801 -1.36712,-0.14519 z m 12.71487,-2.88678 c 4.07483,-2.16492 6.49181,-6.91573 5.77688,-11.35503 -1.43301,-8.89818 -12.26662,-12.56077 -18.55452,-6.27286 -2.5869,2.58689 -4.01263,7.04752 -3.19076,9.98285 0.17092,0.61048 0.20486,0.59258 0.9172,-0.48385 1.27366,-1.92463 3.66014,-2.39926 5.13086,-1.02045 0.3554,0.33318 1.60461,2.21313 2.77603,4.17766 3.91662,6.56835 3.59108,6.15425 4.67934,5.95247 0.52288,-0.097 1.63212,-0.5383 2.46497,-0.98079 z m -5.82698,-8.99334 c -0.33405,-0.33406 -0.46695,-6.65809 -0.1569,-7.46607 0.18928,-0.49325 1.18496,-0.55463 1.63857,-0.10102 0.20584,0.20585 0.3175,1.27588 0.3175,3.04271 v 2.72521 h 2.23965 c 1.96886,0 2.27389,0.064 2.52285,0.52917 0.20121,0.37597 0.20121,0.68236 0,1.05833 -0.25747,0.48109 -0.55399,0.52917 -3.26368,0.52917 -1.96538,0 -3.08862,-0.10814 -3.29799,-0.3175 z" 33 | style="fill:#000000;stroke-width:0.26458335" /> 34 | </g> 35 | </svg> 36 | -------------------------------------------------------------------------------- /html-templates/assets/img/refresh.svg: -------------------------------------------------------------------------------- 1 | <svg xmlns="http://www.w3.org/2000/svg" width="23.985" height="24" viewBox="0 0 23.985 24"> 2 | <path id="Icon_material-refresh" data-name="Icon material-refresh" d="M26.475,9.525A12,12,0,1,0,29.6,21h-3.12A9,9,0,1,1,18,9a8.872,8.872,0,0,1,6.33,2.67L19.5,16.5H30V6Z" transform="translate(-6.015 -6)" fill="#0048c4"/> 3 | </svg> 4 | -------------------------------------------------------------------------------- /html-templates/assets/img/spinner.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 | <svg 3 | width="48" 4 | height="48" 5 | viewBox="0 0 48 48" 6 | version="1.1" 7 | id="svg1" 8 | sodipodi:docname="spinner.svg" 9 | inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)" 10 | xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" 11 | xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 12 | xmlns="http://www.w3.org/2000/svg" 13 | xmlns:svg="http://www.w3.org/2000/svg"> 14 | <defs 15 | id="defs1" /> 16 | <sodipodi:namedview 17 | id="namedview1" 18 | pagecolor="#ffffff" 19 | bordercolor="#000000" 20 | borderopacity="0.25" 21 | inkscape:showpageshadow="2" 22 | inkscape:pageopacity="0.0" 23 | inkscape:pagecheckerboard="0" 24 | inkscape:deskcolor="#d1d1d1" 25 | inkscape:zoom="6.5628348" 26 | inkscape:cx="56.454263" 27 | inkscape:cy="16.989609" 28 | inkscape:window-width="1920" 29 | inkscape:window-height="1009" 30 | inkscape:window-x="3832" 31 | inkscape:window-y="-8" 32 | inkscape:window-maximized="1" 33 | inkscape:current-layer="svg1" /> 34 | <path 35 | id="Icon_material-refresh" 36 | data-name="Icon material-refresh" 37 | d="M 41.026162,7.5363953 C 23.829804,-9.6119325 -5.0484867,7.0536883 1.3448609,30.41487 7.7382086,53.77605 41.120858,53.569275 47.204563,30.112169 H 41.036047 C 36.223718,43.656698 18.059973,46.160203 9.7286108,34.42839 1.3972477,22.696576 9.8333604,6.4950865 24.270334,6.5035165 c 4.711297,0.00754 9.221525,1.9005387 12.514971,5.2529245 L 47.9954,0.60135275 Z" 38 | fill="#0048c4" 39 | sodipodi:nodetypes="csccscccc" 40 | style="stroke-width:1.97223" /> 41 | </svg> 42 | -------------------------------------------------------------------------------- /html-templates/wallet_howto.html: -------------------------------------------------------------------------------- 1 | <html> 2 | <head> 3 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 4 | <!-- Bootstrap --> 5 | <link href="/static/css/bootstrap.533.min.css" rel="stylesheet" /> 6 | <style> 7 | body { 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | font-family: "Arial", sans-serif; 12 | color: #313132; 13 | } 14 | .test { 15 | flex: none; 16 | align-self: stretch; 17 | 18 | } 19 | .title { 20 | font-size: 2rem; 21 | } 22 | .icon { 23 | margin-top: 2rem; 24 | } 25 | .icon svg { 26 | height: 8rem; 27 | width: 8rem; 28 | } 29 | </style> 30 | <link href="/static/css/custom.css" rel="stylesheet" /> 31 | </head> 32 | <body> 33 | <div class="text-center mx-3" style=" max-width: 500px;"> 34 | <div class="title fw-bold mb-3 px-4"> 35 | Please scan with a digital wallet 36 | </div> 37 | <div class="test fs-4"> 38 | This QR code can only be scanned with a digital wallet app. 39 | </div> 40 | <div class="icon"> 41 | <img src="/static/img/new-digital-wallet.svg" alt="Digital Wallet" /> 42 | </div> 43 | <div id="open-in-wallet" class="mobile-device content"> 44 | <div class="fs-4 py-2 mb-1"> 45 | Don't have a digital wallet? 46 | </div> 47 | <a 48 | id="download-link" 49 | href="https://www2.gov.bc.ca/gov/content/governments/government-id/bc-wallet" 50 | class="fs-4 btn btn-lg btn-default btn-block-secondary btn-outline-primary w-100 mb-2 border-2 rounded-1 fw-bold" 51 | title="Download BC Wallet" 52 | target="_blank" 53 | > 54 | Download BC Wallet 55 | </a> 56 | </div> 57 | <div class="fs-4 p-4"> 58 | <a href="https://www2.gov.bc.ca/gov/content/governments/government-id/bc-wallet"> 59 | Learn more on digital wallets 60 | </a> 61 | </div> 62 | </div> 63 | </body> 64 | <script> 65 | const getBrowser = () => { 66 | let userAgent = navigator.userAgent || navigator.vendor; 67 | 68 | if (/android/i.test(userAgent)) { 69 | return "Android"; 70 | } 71 | 72 | if ( 73 | /iPad|iPhone|iPod/.test(userAgent) || 74 | (/Macintosh/.test(userAgent) && "ontouchend" in document) || 75 | (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) || 76 | (navigator.vendor && navigator.vendor.indexOf("Apple") > -1) 77 | ) { 78 | return "iOS"; 79 | } 80 | 81 | return "unknown"; 82 | } 83 | 84 | const isMobile = () => (getBrowser() === "Android" || getBrowser() === "iOS") 85 | try { 86 | if (isMobile()) { 87 | // Set the url for downloading the wallet depending on platform 88 | document.getElementById("download-link").href = getBrowser() === "Android" 89 | ? "https://play.google.com/store/apps/details?id=ca.bc.gov.BCWallet" 90 | : "https://apps.apple.com/us/app/bc-wallet/id1587380443" 91 | // Attempt to open the BC wallet if installed 92 | window.location.href = "{{wallet_deep_link}}" 93 | } else { 94 | document.getElementById("open-in-wallet").style.display = "none"; 95 | } 96 | } catch (e) { 97 | console.log("Error hiding browser element", e); 98 | } 99 | </script> 100 | </html> 101 | 102 | 103 | -------------------------------------------------------------------------------- /oidc-controller/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | **/tests/* 4 | *test* 5 | *__init__* 6 | 7 | -------------------------------------------------------------------------------- /oidc-controller/api/authSessions/crud.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | 3 | from pymongo import ReturnDocument 4 | from pymongo.database import Database 5 | from fastapi import HTTPException 6 | from fastapi import status as http_status 7 | from fastapi.encoders import jsonable_encoder 8 | 9 | from ..core.models import PyObjectId 10 | from .models import ( 11 | AuthSession, 12 | AuthSessionCreate, 13 | AuthSessionPatch, 14 | ) 15 | from api.db.session import COLLECTION_NAMES 16 | 17 | 18 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 19 | 20 | 21 | class AuthSessionCRUD: 22 | def __init__(self, db: Database): 23 | self._db = db 24 | 25 | async def create(self, auth_session: AuthSessionCreate) -> AuthSession: 26 | col = self._db.get_collection(COLLECTION_NAMES.AUTH_SESSION) 27 | result = col.insert_one(jsonable_encoder(auth_session)) 28 | return AuthSession(**col.find_one({"_id": result.inserted_id})) 29 | 30 | async def get(self, id: str) -> AuthSession: 31 | if not PyObjectId.is_valid(id): 32 | raise HTTPException( 33 | status_code=http_status.HTTP_400_BAD_REQUEST, detail=f"Invalid id: {id}" 34 | ) 35 | col = self._db.get_collection(COLLECTION_NAMES.AUTH_SESSION) 36 | auth_sess = col.find_one({"_id": PyObjectId(id)}) 37 | 38 | if auth_sess is None: 39 | raise HTTPException( 40 | status_code=http_status.HTTP_404_NOT_FOUND, 41 | detail="The auth_session hasn't been found!", 42 | ) 43 | 44 | return AuthSession(**auth_sess) 45 | 46 | async def patch(self, id: str | PyObjectId, data: AuthSessionPatch) -> AuthSession: 47 | if not PyObjectId.is_valid(id): 48 | raise HTTPException( 49 | status_code=http_status.HTTP_400_BAD_REQUEST, detail=f"Invalid id: {id}" 50 | ) 51 | col = self._db.get_collection(COLLECTION_NAMES.AUTH_SESSION) 52 | auth_sess = col.find_one_and_update( 53 | {"_id": PyObjectId(id)}, 54 | {"$set": data.model_dump(exclude_unset=True)}, 55 | return_document=ReturnDocument.AFTER, 56 | ) 57 | 58 | return auth_sess 59 | 60 | async def delete(self, id: str) -> bool: 61 | if not PyObjectId.is_valid(id): 62 | raise HTTPException( 63 | status_code=http_status.HTTP_400_BAD_REQUEST, detail=f"Invalid id: {id}" 64 | ) 65 | col = self._db.get_collection(COLLECTION_NAMES.AUTH_SESSION) 66 | auth_sess = col.find_one_and_delete({"_id": PyObjectId(id)}) 67 | return bool(auth_sess) 68 | 69 | async def get_by_pres_exch_id(self, pres_exch_id: str) -> AuthSession: 70 | col = self._db.get_collection(COLLECTION_NAMES.AUTH_SESSION) 71 | auth_sess = col.find_one({"pres_exch_id": pres_exch_id}) 72 | 73 | if auth_sess is None: 74 | raise HTTPException( 75 | status_code=http_status.HTTP_404_NOT_FOUND, 76 | detail="The auth_session hasn't been found with that pres_exch_id!", 77 | ) 78 | 79 | return AuthSession(**auth_sess) 80 | 81 | async def get_by_pyop_auth_code(self, code: str) -> AuthSession: 82 | col = self._db.get_collection(COLLECTION_NAMES.AUTH_SESSION) 83 | auth_sess = col.find_one({"pyop_auth_code": code}) 84 | 85 | if auth_sess is None: 86 | raise HTTPException( 87 | status_code=http_status.HTTP_404_NOT_FOUND, 88 | detail="The auth_session hasn't been found with that pyop_auth_code!", 89 | ) 90 | 91 | return AuthSession(**auth_sess) 92 | -------------------------------------------------------------------------------- /oidc-controller/api/authSessions/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from enum import StrEnum, auto 3 | 4 | from api.core.models import UUIDModel 5 | from pydantic import BaseModel, ConfigDict, Field 6 | 7 | from ..core.config import settings 8 | 9 | 10 | class AuthSessionState(StrEnum): 11 | NOT_STARTED = auto() 12 | PENDING = auto() 13 | EXPIRED = auto() 14 | VERIFIED = auto() 15 | FAILED = auto() 16 | ABANDONED = auto() 17 | 18 | 19 | class AuthSessionBase(BaseModel): 20 | pres_exch_id: str 21 | expired_timestamp: datetime = Field( 22 | default=datetime.now() 23 | + timedelta(seconds=settings.CONTROLLER_PRESENTATION_EXPIRE_TIME) 24 | ) 25 | ver_config_id: str 26 | request_parameters: dict 27 | pyop_auth_code: str 28 | response_url: str 29 | presentation_request_msg: dict | None = None 30 | model_config = ConfigDict(populate_by_name=True) 31 | created_at: datetime = Field(default_factory=datetime.utcnow) 32 | 33 | 34 | class AuthSession(AuthSessionBase, UUIDModel): 35 | proof_status: AuthSessionState = Field(default=AuthSessionState.NOT_STARTED) 36 | presentation_exchange: dict | None = Field(default_factory=dict) 37 | 38 | 39 | class AuthSessionCreate(AuthSessionBase): 40 | pass 41 | 42 | 43 | class AuthSessionPatch(AuthSessionBase): 44 | proof_status: AuthSessionState = Field(default=AuthSessionState.PENDING) 45 | presentation_exchange: dict = Field(default_factory=dict) 46 | pass 47 | -------------------------------------------------------------------------------- /oidc-controller/api/clientConfigurations/crud.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | 3 | from pymongo import ReturnDocument 4 | from pymongo.database import Database 5 | from fastapi.encoders import jsonable_encoder 6 | 7 | from ..core.http_exception_util import ( 8 | raise_appropriate_http_exception, 9 | check_and_raise_not_found_http_exception, 10 | ) 11 | from ..core.oidc.provider import init_provider 12 | from ..db.session import COLLECTION_NAMES 13 | 14 | from .models import ( 15 | ClientConfiguration, 16 | ClientConfigurationPatch, 17 | ) 18 | 19 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 20 | 21 | NOT_FOUND_MSG = "The requested client configuration wasn't found" 22 | 23 | 24 | class ClientConfigurationCRUD: 25 | def __init__(self, db: Database): 26 | self._db = db 27 | 28 | async def create(self, client_config: ClientConfiguration) -> ClientConfiguration: 29 | col = self._db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS) 30 | try: 31 | col.insert_one(jsonable_encoder(client_config)) 32 | except Exception as err: 33 | raise_appropriate_http_exception( 34 | err, exists_msg="Client configuration already exists" 35 | ) 36 | 37 | # remake provider instance to refresh provider client 38 | await init_provider(self._db) 39 | return ClientConfiguration( 40 | **col.find_one({"client_id": client_config.client_id}) 41 | ) 42 | 43 | async def get(self, client_id: str) -> ClientConfiguration: 44 | col = self._db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS) 45 | obj = col.find_one({"client_id": client_id}) 46 | check_and_raise_not_found_http_exception(obj, NOT_FOUND_MSG) 47 | 48 | return ClientConfiguration(**obj) 49 | 50 | async def get_all(self) -> list[ClientConfiguration]: 51 | col = self._db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS) 52 | return [ClientConfiguration(**cc) for cc in col.find()] 53 | 54 | async def patch( 55 | self, client_id: str, data: ClientConfigurationPatch 56 | ) -> ClientConfiguration: 57 | col = self._db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS) 58 | obj = col.find_one_and_update( 59 | {"client_id": client_id}, 60 | {"$set": data.model_dump(exclude_unset=True)}, 61 | return_document=ReturnDocument.AFTER, 62 | ) 63 | check_and_raise_not_found_http_exception(obj, NOT_FOUND_MSG) 64 | 65 | # remake provider instance to refresh provider client 66 | await init_provider(self._db) 67 | return obj 68 | 69 | async def delete(self, client_id: str) -> bool: 70 | col = self._db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS) 71 | obj = col.find_one_and_delete({"client_id": client_id}) 72 | check_and_raise_not_found_http_exception(obj, NOT_FOUND_MSG) 73 | 74 | # remake provider instance to refresh provider client 75 | await init_provider(self._db) 76 | return bool(obj) 77 | -------------------------------------------------------------------------------- /oidc-controller/api/clientConfigurations/examples.py: -------------------------------------------------------------------------------- 1 | from api.core.config import settings 2 | 3 | ex_client_config = { 4 | "client_id": settings.OIDC_CLIENT_ID, 5 | "client_name": settings.OIDC_CLIENT_NAME, 6 | "client_secret": "**********", 7 | "response_types": ["code", "id_token", "token"], 8 | "token_endpoint_auth_method": "client_secret_basic", 9 | "redirect_uris": [settings.OIDC_CLIENT_REDIRECT_URI], 10 | } 11 | -------------------------------------------------------------------------------- /oidc-controller/api/clientConfigurations/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from pydantic import BaseModel, ConfigDict, Field 4 | 5 | from ..core.config import settings 6 | from .examples import ex_client_config 7 | 8 | 9 | class TOKENENDPOINTAUTHMETHODS(str, Enum): 10 | client_secret_basic = "client_secret_basic" 11 | client_secret_post = "client_secret_post" 12 | 13 | @classmethod 14 | def list(cls): 15 | return list(map(lambda c: c.value, cls)) 16 | 17 | 18 | class ClientConfigurationBase(BaseModel): 19 | client_id: str = Field(default=settings.OIDC_CLIENT_ID) 20 | client_name: str = Field(default=settings.OIDC_CLIENT_NAME) 21 | response_types: list[str] = Field(default=["code", "id_token", "token"]) 22 | redirect_uris: list[str] 23 | token_endpoint_auth_method: TOKENENDPOINTAUTHMETHODS = Field( 24 | default=TOKENENDPOINTAUTHMETHODS.client_secret_basic 25 | ) 26 | 27 | client_secret: str = Field(default=settings.OIDC_CLIENT_SECRET) 28 | 29 | model_config = ConfigDict( 30 | populate_by_name=True, json_schema_extra={"example": ex_client_config} 31 | ) 32 | 33 | 34 | class ClientConfiguration(ClientConfigurationBase): 35 | pass 36 | 37 | 38 | class ClientConfigurationRead(ClientConfigurationBase): 39 | pass 40 | 41 | 42 | class ClientConfigurationPatch(ClientConfigurationBase): 43 | client_id: str | None = None 44 | client_name: str | None = None 45 | response_types: list[str] | None = None 46 | redirect_uris: list[str] | None = None 47 | token_endpoint_auth_method: TOKENENDPOINTAUTHMETHODS | None = None 48 | client_secret: str | None = None 49 | 50 | pass 51 | -------------------------------------------------------------------------------- /oidc-controller/api/clientConfigurations/router.py: -------------------------------------------------------------------------------- 1 | from pymongo.database import Database 2 | 3 | from fastapi import APIRouter, Depends 4 | from fastapi import status as http_status 5 | 6 | from .crud import ClientConfigurationCRUD 7 | from .models import ( 8 | ClientConfiguration, 9 | ClientConfigurationPatch, 10 | ClientConfigurationRead, 11 | ) 12 | from ..core.auth import get_api_key 13 | from ..core.models import GenericErrorMessage, StatusMessage 14 | from ..db.session import get_db 15 | 16 | 17 | router = APIRouter() 18 | 19 | 20 | @router.post( 21 | "/", 22 | response_description="Add new client configuration", 23 | status_code=http_status.HTTP_201_CREATED, 24 | response_model=ClientConfiguration, 25 | responses={http_status.HTTP_409_CONFLICT: {"model": GenericErrorMessage}}, 26 | response_model_exclude_unset=True, 27 | dependencies=[Depends(get_api_key)], 28 | ) 29 | async def create_client_config( 30 | client_config: ClientConfiguration, db: Database = Depends(get_db) 31 | ): 32 | return await ClientConfigurationCRUD(db).create(client_config) 33 | 34 | 35 | @router.get( 36 | "/", 37 | status_code=http_status.HTTP_200_OK, 38 | response_model=list[ClientConfigurationRead], 39 | response_model_exclude_unset=True, 40 | dependencies=[Depends(get_api_key)], 41 | ) 42 | async def get_all_client_configs(db: Database = Depends(get_db)): 43 | return await ClientConfigurationCRUD(db).get_all() 44 | 45 | 46 | @router.get( 47 | "/{client_id}", 48 | status_code=http_status.HTTP_200_OK, 49 | response_model=ClientConfigurationRead, 50 | responses={http_status.HTTP_404_NOT_FOUND: {"model": GenericErrorMessage}}, 51 | response_model_exclude_unset=True, 52 | dependencies=[Depends(get_api_key)], 53 | ) 54 | async def get_client_config(client_id: str, db: Database = Depends(get_db)): 55 | return await ClientConfigurationCRUD(db).get(client_id) 56 | 57 | 58 | @router.patch( 59 | "/{client_id}", 60 | status_code=http_status.HTTP_200_OK, 61 | response_model=ClientConfigurationRead, 62 | responses={http_status.HTTP_404_NOT_FOUND: {"model": GenericErrorMessage}}, 63 | response_model_exclude_unset=True, 64 | dependencies=[Depends(get_api_key)], 65 | ) 66 | async def patch_client_config( 67 | client_id: str, 68 | data: ClientConfigurationPatch, 69 | db: Database = Depends(get_db), 70 | ): 71 | return await ClientConfigurationCRUD(db).patch(client_id=client_id, data=data) 72 | 73 | 74 | @router.delete( 75 | "/{client_id}", 76 | status_code=http_status.HTTP_200_OK, 77 | response_model=StatusMessage, 78 | responses={http_status.HTTP_404_NOT_FOUND: {"model": GenericErrorMessage}}, 79 | dependencies=[Depends(get_api_key)], 80 | ) 81 | async def delete_client_config(client_id: str, db: Database = Depends(get_db)): 82 | status = await ClientConfigurationCRUD(db).delete(client_id) 83 | return StatusMessage(status=status, message="The client configuration was deleted") 84 | -------------------------------------------------------------------------------- /oidc-controller/api/clientConfigurations/tests/test_cc_crud.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from api.clientConfigurations.crud import ClientConfigurationCRUD 4 | from api.clientConfigurations.models import ( 5 | ClientConfiguration, 6 | ClientConfigurationPatch, 7 | ) 8 | 9 | from api.db.session import COLLECTION_NAMES 10 | 11 | from mongomock import MongoClient 12 | from typing import Callable 13 | import structlog 14 | 15 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 16 | 17 | 18 | def test_answer(): 19 | assert True 20 | 21 | 22 | test_client_config = ClientConfiguration( 23 | client_id="test_client", 24 | client_name="test_client_name", 25 | client_secret="test_client_secret", 26 | redirect_uris=["http://redirecturi.com"], 27 | ) 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_client_config_get(db_client: Callable[[], MongoClient]): 32 | client = db_client() 33 | crud = ClientConfigurationCRUD(client.db) 34 | 35 | client.db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS).insert_one( 36 | test_client_config.model_dump() 37 | ) 38 | 39 | result = await crud.get(test_client_config.client_id) 40 | assert result 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_client_config_create(db_client: Callable[[], MongoClient]): 45 | client = db_client() 46 | crud = ClientConfigurationCRUD(client.db) 47 | 48 | await crud.create(test_client_config) 49 | document = client.db.get_collection( 50 | COLLECTION_NAMES.CLIENT_CONFIGURATIONS 51 | ).find_one({"client_id": test_client_config.client_id}) 52 | assert document 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_client_config_delete(db_client: Callable[[], MongoClient]): 57 | client = db_client() 58 | crud = ClientConfigurationCRUD(client.db) 59 | 60 | client.db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS).insert_one( 61 | test_client_config.model_dump() 62 | ) 63 | 64 | result = await crud.delete(test_client_config.client_id) 65 | assert result 66 | 67 | document = client.db.get_collection( 68 | COLLECTION_NAMES.CLIENT_CONFIGURATIONS 69 | ).find_one({"client_id": test_client_config.client_id}) 70 | assert not document 71 | 72 | 73 | @pytest.fixture(name="log_output") 74 | def fixture_log_output(): 75 | return structlog.testing.LogCapture() 76 | 77 | 78 | @pytest.fixture(autouse=True) 79 | def fixture_configure_structlog(log_output): 80 | structlog.configure(processors=[log_output]) 81 | 82 | 83 | @pytest.mark.asyncio 84 | async def test_client_config_patch(db_client: Callable[[], MongoClient], log_output): 85 | client = db_client() 86 | crud = ClientConfigurationCRUD(client.db) 87 | 88 | client.db.get_collection(COLLECTION_NAMES.CLIENT_CONFIGURATIONS).insert_one( 89 | test_client_config.model_dump() 90 | ) 91 | 92 | assert log_output.entries == [] 93 | 94 | result = await crud.patch( 95 | test_client_config.client_id, 96 | ClientConfigurationPatch(client_secret="patched_client_secret"), 97 | ) 98 | assert result 99 | document = client.db.get_collection( 100 | COLLECTION_NAMES.CLIENT_CONFIGURATIONS 101 | ).find_one({"client_id": test_client_config.client_id}) 102 | assert document["client_secret"] == "patched_client_secret" 103 | -------------------------------------------------------------------------------- /oidc-controller/api/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/oidc-controller/api/core/__init__.py -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from .present_proof_attachment import PresentProofv20Attachment 4 | from .service_decorator import ServiceDecorator, OOBServiceDecorator 5 | 6 | from .present_proof_presentation import PresentationRequestMessage 7 | from .out_of_band import OutOfBandMessage, OutOfBandPresentProofAttachment 8 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/config.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import structlog 3 | import json 4 | 5 | from functools import cache 6 | from typing import Protocol 7 | 8 | from ..config import settings 9 | 10 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 11 | 12 | 13 | class AgentConfig(Protocol): 14 | def get_headers() -> dict[str, str]: ... 15 | 16 | 17 | class MultiTenantAcapy: 18 | wallet_id = settings.MT_ACAPY_WALLET_ID 19 | wallet_key = settings.MT_ACAPY_WALLET_KEY 20 | 21 | @cache 22 | def get_wallet_token(self): 23 | logger.debug(">>> get_wallet_token") 24 | resp_raw = requests.post( 25 | settings.ACAPY_ADMIN_URL + f"/multitenancy/wallet/{self.wallet_id}/token", 26 | ) 27 | assert ( 28 | resp_raw.status_code == 200 29 | ), f"{resp_raw.status_code}::{resp_raw.content}" 30 | resp = json.loads(resp_raw.content) 31 | wallet_token = resp["token"] 32 | logger.debug("<<< get_wallet_token") 33 | 34 | return wallet_token 35 | 36 | def get_headers(self) -> dict[str, str]: 37 | return {"Authorization": "Bearer " + self.get_wallet_token()} 38 | 39 | 40 | class SingleTenantAcapy: 41 | def get_headers(self) -> dict[str, str]: 42 | return {settings.ST_ACAPY_ADMIN_API_KEY_NAME: settings.ST_ACAPY_ADMIN_API_KEY} 43 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/models.py: -------------------------------------------------------------------------------- 1 | from . import OutOfBandMessage 2 | 3 | from pydantic import BaseModel 4 | 5 | 6 | class WalletDid(BaseModel): 7 | did: str 8 | verkey: str 9 | posture: str 10 | 11 | 12 | class WalletDidPublicResponse(BaseModel): 13 | result: WalletDid | None = None 14 | 15 | 16 | class CreatePresentationResponse(BaseModel): 17 | thread_id: str 18 | pres_ex_id: str 19 | pres_request: dict 20 | 21 | 22 | class OobCreateInvitationResponse(BaseModel): 23 | invi_msg_id: str 24 | invitation_url: str 25 | oob_id: str 26 | trace: bool 27 | state: str 28 | invitation: OutOfBandMessage 29 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/out_of_band.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, Field 2 | 3 | from .service_decorator import OOBServiceDecorator 4 | 5 | 6 | class OutOfBandPresentProofAttachment(BaseModel): 7 | id: str = Field(alias="@id") 8 | mime_type: str = Field(default="application/json", alias="mime-type") 9 | data: dict 10 | 11 | model_config = ConfigDict(populate_by_name=True) 12 | 13 | 14 | class OutOfBandMessage(BaseModel): 15 | # https://github.com/hyperledger/aries-rfcs/blob/main/features/0434-outofband 16 | id: str = Field(alias="@id") 17 | type: str = Field( 18 | default="https://didcomm.org/out-of-band/1.1/invitation", 19 | alias="@type", 20 | ) 21 | goal_code: str = Field(default="aries.vc.verifier.once") 22 | label: str = Field( 23 | default="acapy-vc-authn Out-of-Band present-proof authorization request" 24 | ) 25 | request_attachments: list[OutOfBandPresentProofAttachment] = Field( 26 | alias="requests~attach" 27 | ) 28 | services: list[OOBServiceDecorator | str] = Field(alias="services") 29 | handshake_protocols: list[str] = Field(alias="handshake_protocols", default=None) 30 | 31 | model_config = ConfigDict(populate_by_name=True) 32 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/present_proof_attachment.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class PresentProofv20Attachment(BaseModel): 5 | # https://github.com/hyperledger/aries-rfcs/tree/eace815c3e8598d4a8dd7881d8c731fdb2bcc0aa/features/0454-present-proof-v2 6 | id: str = Field(default="libindy-request-presentation-0", alias="@id") 7 | mime_type: str = Field(default="application/json", alias="mime-type") 8 | data: dict 9 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/present_proof_presentation.py: -------------------------------------------------------------------------------- 1 | import json 2 | import base64 3 | 4 | from pydantic import BaseModel, ConfigDict, Field 5 | from api.core.acapy import PresentProofv20Attachment, ServiceDecorator 6 | 7 | 8 | class PresentationRequestMessage(BaseModel): 9 | # https://github.com/hyperledger/aries-rfcs/blob/main/features/0037-present-proof/README.md#presentation 10 | id: str = Field(alias="@id") 11 | type: str = Field( 12 | "https://didcomm.org/present-proof/2.0/request-presentation", 13 | alias="@type", 14 | ) 15 | formats: list[dict] 16 | request: list[PresentProofv20Attachment] = Field( 17 | alias="request_presentations~attach" 18 | ) 19 | comment: str | None = None 20 | service: ServiceDecorator | None = Field(None, alias="~service") 21 | 22 | model_config = ConfigDict(populate_by_name=True) 23 | 24 | def b64_str(self): 25 | # object->dict->jsonString->ascii->ENCODE->ascii 26 | return base64.b64encode( 27 | json.dumps(self.model_dump(by_alias=True)).encode("ascii") 28 | ).decode("ascii") 29 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/service_decorator.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, ConfigDict, Field 2 | 3 | 4 | class ServiceDecorator(BaseModel): 5 | # https://github.com/hyperledger/aries-rfcs/tree/main/features/0056-service-decorator 6 | recipient_keys: list[str] | None = Field(default=None, alias="recipientKeys") 7 | routing_keys: list[str] | None = Field(default=None, alias="routingKeys") 8 | service_endpoint: str | None = Field(default=None, alias="serviceEndpoint") 9 | 10 | model_config = ConfigDict(populate_by_name=True) 11 | 12 | 13 | class OOBServiceDecorator(ServiceDecorator): 14 | # ServiceDecorator 15 | recipient_keys: list[str] | None = Field(default=None, alias="recipientKeys") 16 | routing_keys: list[str] | None = Field(default=None, alias="routingKeys") 17 | service_endpoint: str | None = Field(default=None, alias="serviceEndpoint") 18 | id: str = Field( 19 | default="did:acapy-vc-authn-oidc:123456789zyxwvutsr#did-communication" 20 | ) 21 | type: str = Field(default="did-communication") 22 | priority: int = 0 23 | 24 | model_config = ConfigDict(populate_by_name=True) 25 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/tests/__mocks__.py: -------------------------------------------------------------------------------- 1 | presentation_request_configuration = { 2 | "name": "proof_requested", 3 | "version": "0.0.1", 4 | "requested_attributes": { 5 | "req_attr_0": { 6 | "names": ["email"], 7 | "restrictions": [ 8 | { 9 | "schema_name": "verified-email", 10 | "issuer_did": "MTYqmTBoLT7KLP5RNfgK3b", 11 | } 12 | ], 13 | "non_revoked": {"from": 1695320203, "to": 1695320203}, 14 | } 15 | }, 16 | "requested_predicates": {}, 17 | } 18 | 19 | presentation_request = { 20 | "nonce": "136042354083201173353396", 21 | "name": "proof_requested", 22 | "version": "0.0.1", 23 | "requested_attributes": { 24 | "req_attr_0": { 25 | "non_revoked": {"from": 1695321803, "to": 1695321803}, 26 | "restrictions": [ 27 | { 28 | "schema_name": "verified-email", 29 | "issuer_did": "MTYqmTBoLT7KLP5RNfgK3b", 30 | } 31 | ], 32 | "names": ["email"], 33 | } 34 | }, 35 | "requested_predicates": {}, 36 | } 37 | 38 | create_presentation_response_http = { 39 | "updated_at": "2023-09-21T18:43:23.470373Z", 40 | "role": "verifier", 41 | "presentation_exchange_id": "b2945790-79c4-4059-9f93-6bd43b2186f7", 42 | "created_at": "2023-09-21T18:43:23.470373Z", 43 | "trace": False, 44 | "thread_id": "ab2e3f02-6e16-4e08-8165-5ddc7aad3090", 45 | "initiator": "self", 46 | "state": "request_sent", 47 | "presentation_request": presentation_request, 48 | "auto_verify": True, 49 | "presentation_request_dict": { 50 | "@type": "did:sov:BzCbsNYhMrjHiqZDTUASHg;spec/present-proof/1.0/request-presentation", 51 | "@id": "ab2e3f02-6e16-4e08-8165-5ddc7aad3090", 52 | "request_presentations~attach": [ 53 | { 54 | "@id": "libindy-request-presentation-0", 55 | "mime-type": "application/json", 56 | "data": { 57 | "base64": "eyJuYW1lIjogInByb29mX3JlcXVlc3RlZCIsICJ2ZXJzaW9uIjogIjAuMC4xIiwgInJlcXVlc3RlZF9hdHRyaWJ1dGVzIjogeyJyZXFfYXR0cl8wIjogeyJuYW1lcyI6IFsiZW1haWwiXSwgInJlc3RyaWN0aW9ucyI6IFt7InNjaGVtYV9uYW1lIjogInZlcmlmaWVkLWVtYWlsIiwgImlzc3Vlcl9kaWQiOiAiTVRZcW1UQm9MVDdLTFA1Uk5mZ0szYiJ9XSwgIm5vbl9yZXZva2VkIjogeyJmcm9tIjogMTY5NTMyMTgwMywgInRvIjogMTY5NTMyMTgwM319fSwgInJlcXVlc3RlZF9wcmVkaWNhdGVzIjoge30sICJub25jZSI6ICIxMzYwNDIzNTQwODMyMDExNzMzNTMzOTYifQ==" 58 | }, 59 | } 60 | ], 61 | }, 62 | "auto_present": False, 63 | } 64 | -------------------------------------------------------------------------------- /oidc-controller/api/core/acapy/tests/test_config.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | from api.core.acapy.config import MultiTenantAcapy, SingleTenantAcapy 4 | from api.core.config import settings 5 | 6 | 7 | @pytest.mark.asyncio 8 | @mock.patch.object(settings, "ST_ACAPY_ADMIN_API_KEY_NAME", "name") 9 | @mock.patch.object(settings, "ST_ACAPY_ADMIN_API_KEY", "key") 10 | async def test_single_tenant_has_expected_headers(): 11 | acapy = SingleTenantAcapy() 12 | headers = acapy.get_headers() 13 | assert headers == {"name": "key"} 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_multi_tenant_get_headers_returns_bearer_token_auth(requests_mock): 18 | acapy = MultiTenantAcapy() 19 | acapy.get_wallet_token = mock.MagicMock(return_value="token") 20 | headers = acapy.get_headers() 21 | assert headers == {"Authorization": "Bearer token"} 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_multi_tenant_get_wallet_token_returns_token_at_token_key(requests_mock): 26 | requests_mock.post( 27 | settings.ACAPY_ADMIN_URL + "/multitenancy/wallet/wallet_id/token", 28 | headers={}, 29 | json={"token": "token"}, 30 | status_code=200, 31 | ) 32 | acapy = MultiTenantAcapy() 33 | acapy.wallet_id = "wallet_id" 34 | token = acapy.get_wallet_token() 35 | assert token == "token" 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_multi_tenant_throws_assertion_error_for_non_200_response(requests_mock): 40 | requests_mock.post( 41 | settings.ACAPY_ADMIN_URL + "/multitenancy/wallet/wallet_id/token", 42 | headers={}, 43 | json={"token": "token"}, 44 | status_code=400, 45 | ) 46 | acapy = MultiTenantAcapy() 47 | acapy.wallet_id = "wallet_id" 48 | try: 49 | acapy.get_wallet_token() 50 | except AssertionError as e: 51 | assert e is not None 52 | -------------------------------------------------------------------------------- /oidc-controller/api/core/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi.security import APIKeyHeader 2 | from fastapi import Security, HTTPException, status 3 | from .config import settings 4 | 5 | api_key_header = APIKeyHeader(name="x-api-key", auto_error=False) 6 | API_KEY = settings.CONTROLLER_API_KEY 7 | 8 | 9 | async def get_api_key(api_key_header: str = Security(api_key_header)): 10 | if not API_KEY or api_key_header == API_KEY: 11 | return api_key_header 12 | else: 13 | raise HTTPException( 14 | status_code=status.HTTP_403_FORBIDDEN, 15 | detail="Could not validate x-api-key", 16 | ) 17 | -------------------------------------------------------------------------------- /oidc-controller/api/core/http_exception_util.py: -------------------------------------------------------------------------------- 1 | from pymongo.errors import WriteError 2 | from fastapi import HTTPException 3 | from fastapi import status as http_status 4 | import structlog 5 | 6 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 7 | 8 | CONFLICT_DEFAULT_MSG = "The requested resource already exists" 9 | NOT_FOUND_DEFAULT_MSG = "The requested resource wasn't found" 10 | UNKNOWN_DEFAULT_MSG = "The server was unable to process the request" 11 | 12 | 13 | def raise_appropriate_http_exception( 14 | err: WriteError, exists_msg: str = CONFLICT_DEFAULT_MSG 15 | ): 16 | if err.code == 11000: 17 | raise HTTPException( 18 | status_code=http_status.HTTP_409_CONFLICT, 19 | detail=exists_msg, 20 | ) 21 | else: 22 | logger.error("Unknown error", err=err) 23 | raise HTTPException( 24 | status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR, 25 | detail=UNKNOWN_DEFAULT_MSG, 26 | ) 27 | 28 | 29 | def check_and_raise_not_found_http_exception(resp, detail: str = NOT_FOUND_DEFAULT_MSG): 30 | if resp is None: 31 | raise HTTPException( 32 | status_code=http_status.HTTP_404_NOT_FOUND, 33 | detail=detail, 34 | ) 35 | -------------------------------------------------------------------------------- /oidc-controller/api/core/logger_util.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | import time 3 | from typing import Callable, Any 4 | 5 | 6 | def log_debug(func: Callable[..., Any]) -> Callable[..., Any]: 7 | def wrapper(*args, **kwargs): 8 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger( 9 | func.__name__ 10 | ) 11 | logger.debug(f" >>>> {func.__name__}") 12 | logger.debug(f" ..with params={{{args}}}") 13 | start_time = time.time() 14 | ret_val = func(*args, **kwargs) 15 | end_time = time.time() 16 | logger.debug( 17 | f" <<<< {func.__name__} and took {end_time-start_time:.3f} seconds" 18 | ) 19 | logger.debug(f" ..with ret_val={{{ret_val}}}") 20 | return ret_val 21 | 22 | return wrapper 23 | -------------------------------------------------------------------------------- /oidc-controller/api/core/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import TypedDict 3 | 4 | from bson import ObjectId 5 | from pydantic import BaseModel, ConfigDict, Field 6 | from pyop.userinfo import Userinfo 7 | from pydantic_core import core_schema 8 | 9 | 10 | class PyObjectId(ObjectId): 11 | @classmethod 12 | def __get_pydantic_core_schema__( 13 | cls, source_type, handler 14 | ) -> core_schema.CoreSchema: 15 | return core_schema.with_info_plain_validator_function(cls.validate) 16 | 17 | @classmethod 18 | def validate(cls, v, info): 19 | if not ObjectId.is_valid(v): 20 | raise ValueError("Invalid objectid") 21 | return ObjectId(v) 22 | 23 | @classmethod 24 | def __get_pydantic_json_schema__(cls, field_schema): 25 | field_schema.update(type="string") 26 | 27 | 28 | class HealthCheck(BaseModel): 29 | name: str 30 | version: str 31 | description: str 32 | 33 | 34 | class StatusMessage(BaseModel): 35 | status: bool 36 | message: str 37 | 38 | 39 | class UUIDModel(BaseModel): 40 | id: PyObjectId = Field(default_factory=PyObjectId, alias="_id") 41 | 42 | model_config = ConfigDict(json_encoders={ObjectId: str}) 43 | 44 | 45 | class TimestampModel(BaseModel): 46 | created_at: datetime = Field(default_factory=datetime.utcnow) 47 | updated_at: datetime = Field(default_factory=datetime.utcnow) 48 | 49 | 50 | class GenericErrorMessage(BaseModel): 51 | detail: str 52 | 53 | 54 | # Currently used as a TypedDict since it can be used as a part of a 55 | # Pydantic class but a Pydantic class can not inherit from TypedDict 56 | # and and BaseModel 57 | class RevealedAttribute(TypedDict, total=False): 58 | sub_proof_index: int 59 | values: dict 60 | 61 | 62 | class VCUserinfo(Userinfo): 63 | """ 64 | User database for VC-based Identity provider: since no users are 65 | known ahead of time, a new user is created with 66 | every authentication request. 67 | """ 68 | 69 | def __getitem__(self, item): 70 | """ 71 | There is no user info database, we always return an empty dictionary 72 | """ 73 | return {} 74 | 75 | def get_claims_for(self, user_id, requested_claims, userinfo=None): 76 | # type: (str, Mapping[str, Optional[Mapping[str, Union[str, List[str]]]]) 77 | # -> Dict[str, Union[str, List[str]]] 78 | """ 79 | There is no user info database, we always return an empty dictionary 80 | """ 81 | return {} 82 | -------------------------------------------------------------------------------- /oidc-controller/api/core/tests/test_core_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from api.core.config import strtobool 3 | 4 | 5 | def test_strtobool(): 6 | # Test valid truthy values 7 | truthy_values = ["y", "yes", "t", "true", "on", "1", True] 8 | for value in truthy_values: 9 | assert strtobool(value) is True 10 | 11 | # Test valid falsy values 12 | falsy_values = ["n", "no", "f", "false", "off", "0", False] 13 | for value in falsy_values: 14 | assert strtobool(value) is False 15 | 16 | # Test invalid input 17 | with pytest.raises(ValueError, match="invalid truth value invalid"): 18 | strtobool("invalid") 19 | -------------------------------------------------------------------------------- /oidc-controller/api/db/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from .collections import COLLECTION_NAMES 4 | -------------------------------------------------------------------------------- /oidc-controller/api/db/collections.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class COLLECTION_NAMES(str, Enum): 5 | VER_CONFIGS = "verification_configuration" 6 | AUTH_SESSION = "auth_session" 7 | CLIENT_CONFIGURATIONS = "client_configuration" 8 | -------------------------------------------------------------------------------- /oidc-controller/api/logconf.json: -------------------------------------------------------------------------------- 1 | { 2 | "logger": { 3 | "version": 1, 4 | "disable_existing_loggers": false, 5 | "formatters": { 6 | "plain": { 7 | "()": "structlog.stdlib.ProcessorFormatter", 8 | "processors": [ 9 | "renderer" 10 | ], 11 | "foreign_pre_chain": "shared_processors" 12 | } 13 | }, 14 | "handlers": { 15 | "out": { 16 | "formatter": "plain", 17 | "class": "logging.StreamHandler", 18 | "stream": "ext://sys.stdout" 19 | } 20 | }, 21 | "loggers": {} 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /oidc-controller/api/routers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openwallet-foundation/acapy-vc-authn-oidc/7405012922ec9c8d9abcf61376fe8aa0d84049d5/oidc-controller/api/routers/__init__.py -------------------------------------------------------------------------------- /oidc-controller/api/routers/presentation_request.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | 3 | from fastapi import APIRouter, Depends, Request 4 | from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse 5 | from jinja2 import Template 6 | from pymongo.database import Database 7 | 8 | from ..authSessions.crud import AuthSessionCRUD 9 | from ..authSessions.models import AuthSession, AuthSessionState 10 | 11 | from ..core.config import settings 12 | from ..routers.socketio import sio, connections_reload 13 | from ..routers.oidc import gen_deep_link 14 | from ..db.session import get_db 15 | 16 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 17 | 18 | router = APIRouter() 19 | 20 | 21 | async def toggle_pending(db, auth_session: AuthSession): 22 | # We need to set this to pending now 23 | auth_session.proof_status = AuthSessionState.PENDING 24 | await AuthSessionCRUD(db).patch(auth_session.id, auth_session) 25 | connections = connections_reload() 26 | sid = connections.get(str(auth_session.id)) 27 | if sid: 28 | await sio.emit("status", {"status": "pending"}, to=sid) 29 | 30 | 31 | @router.get("/url/pres_exch/{pres_exch_id}") 32 | async def send_connectionless_proof_req( 33 | pres_exch_id: str, req: Request, db: Database = Depends(get_db) 34 | ): 35 | """ 36 | If the user scanes the QR code with a mobile camera, 37 | they will be redirected to a help page. 38 | """ 39 | # First prepare the response depending on the redirect url 40 | if ".html" in settings.CONTROLLER_CAMERA_REDIRECT_URL: 41 | response = RedirectResponse(settings.CONTROLLER_CAMERA_REDIRECT_URL) 42 | else: 43 | template_file = open( 44 | f"{settings.CONTROLLER_TEMPLATE_DIR}/{settings.CONTROLLER_CAMERA_REDIRECT_URL}.html", 45 | "r", 46 | ).read() 47 | 48 | auth_session: AuthSession = await AuthSessionCRUD(db).get_by_pres_exch_id( 49 | pres_exch_id 50 | ) 51 | wallet_deep_link = gen_deep_link(auth_session) 52 | template = Template(template_file) 53 | 54 | # If the qrcode was scanned by mobile phone camera toggle the pending flag 55 | await toggle_pending(db, auth_session) 56 | response = HTMLResponse(template.render({"wallet_deep_link": wallet_deep_link})) 57 | 58 | if "text/html" in req.headers.get("accept"): 59 | logger.info("Redirecting to instructions page") 60 | return response 61 | 62 | auth_session: AuthSession = await AuthSessionCRUD(db).get_by_pres_exch_id( 63 | pres_exch_id 64 | ) 65 | 66 | # Get the websocket session 67 | connections = connections_reload() 68 | sid = connections.get(str(auth_session.id)) 69 | 70 | # If the qrcode has been scanned, toggle the pending flag 71 | if auth_session.proof_status is AuthSessionState.NOT_STARTED: 72 | await toggle_pending(db, auth_session) 73 | 74 | msg = auth_session.presentation_request_msg 75 | 76 | logger.debug(msg) 77 | return JSONResponse(msg) 78 | -------------------------------------------------------------------------------- /oidc-controller/api/routers/socketio.py: -------------------------------------------------------------------------------- 1 | import socketio # For using websockets 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | connections = {} 8 | 9 | sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins="*") 10 | 11 | sio_app = socketio.ASGIApp(socketio_server=sio, socketio_path="/ws/socket.io") 12 | 13 | 14 | @sio.event 15 | async def connect(sid, socket): 16 | logger.info(f">>> connect : sid={sid}") 17 | 18 | 19 | @sio.event 20 | async def initialize(sid, data): 21 | global connections 22 | # Store websocket session matched to the presentation exchange id 23 | connections[data.get("pid")] = sid 24 | 25 | 26 | @sio.event 27 | async def disconnect(sid): 28 | global connections 29 | logger.info(f">>> disconnect : sid={sid}") 30 | # Remove websocket session from the store 31 | if len(connections) > 0: 32 | connections = {k: v for k, v in connections.items() if v != sid} 33 | 34 | 35 | def connections_reload(): 36 | global connections 37 | return connections 38 | -------------------------------------------------------------------------------- /oidc-controller/api/routers/well_known_oid_config.py: -------------------------------------------------------------------------------- 1 | import structlog 2 | 3 | from fastapi import APIRouter 4 | from fastapi.responses import JSONResponse 5 | 6 | # from oic.oauth2.message import ASConfigurationResponse 7 | from ..core.oidc import provider 8 | 9 | logger: structlog.typing.FilteringBoundLogger = structlog.getLogger(__name__) 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.get("/.well-known/openid-configuration", response_class=JSONResponse) 15 | async def get_well_known_oid_config(): 16 | """returns configuration response compliant with 17 | https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse 18 | """ 19 | return provider.configuration_information 20 | 21 | 22 | @router.get("/.well-known/openid-configuration/jwks", response_class=JSONResponse) 23 | async def get_well_known_jwks(): 24 | return {"keys": [provider.signing_key.to_dict()]} 25 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/crud.py: -------------------------------------------------------------------------------- 1 | from fastapi.encoders import jsonable_encoder 2 | 3 | from pymongo import ReturnDocument 4 | from pymongo.database import Database 5 | 6 | from ..core.http_exception_util import ( 7 | raise_appropriate_http_exception, 8 | check_and_raise_not_found_http_exception, 9 | ) 10 | from ..db.session import COLLECTION_NAMES 11 | 12 | from .models import ( 13 | VerificationConfig, 14 | VerificationConfigPatch, 15 | ) 16 | 17 | NOT_FOUND_MSG = "The requested verifier configuration wasn't found" 18 | 19 | 20 | class VerificationConfigCRUD: 21 | _db: Database 22 | 23 | def __init__(self, db: Database): 24 | self._db = db 25 | 26 | async def create(self, ver_config: VerificationConfig) -> VerificationConfig: 27 | ver_confs = self._db.get_collection(COLLECTION_NAMES.VER_CONFIGS) 28 | 29 | try: 30 | ver_confs.insert_one(jsonable_encoder(ver_config)) 31 | except Exception as err: 32 | raise_appropriate_http_exception( 33 | err, exists_msg="Verifier configuration already exists" 34 | ) 35 | return ver_confs.find_one({"ver_config_id": ver_config.ver_config_id}) 36 | 37 | async def get(self, ver_config_id: str) -> VerificationConfig: 38 | ver_confs = self._db.get_collection(COLLECTION_NAMES.VER_CONFIGS) 39 | ver_conf = ver_confs.find_one({"ver_config_id": ver_config_id}) 40 | check_and_raise_not_found_http_exception(ver_conf, NOT_FOUND_MSG) 41 | 42 | return VerificationConfig(**ver_conf) 43 | 44 | async def get_all(self) -> list[VerificationConfig]: 45 | ver_confs = self._db.get_collection(COLLECTION_NAMES.VER_CONFIGS) 46 | return [VerificationConfig(**vc) for vc in ver_confs.find()] 47 | 48 | async def patch( 49 | self, ver_config_id: str, data: VerificationConfigPatch 50 | ) -> VerificationConfig: 51 | if not isinstance(data, VerificationConfigPatch): 52 | raise Exception("please provide an instance of the <document> PATCH class") 53 | ver_confs = self._db.get_collection(COLLECTION_NAMES.VER_CONFIGS) 54 | ver_conf = ver_confs.find_one_and_update( 55 | {"ver_config_id": ver_config_id}, 56 | {"$set": data.model_dump(exclude_unset=True)}, 57 | return_document=ReturnDocument.AFTER, 58 | ) 59 | check_and_raise_not_found_http_exception(ver_conf, NOT_FOUND_MSG) 60 | 61 | return ver_conf 62 | 63 | async def delete(self, ver_config_id: str) -> bool: 64 | ver_confs = self._db.get_collection(COLLECTION_NAMES.VER_CONFIGS) 65 | ver_conf = ver_confs.find_one_and_delete({"ver_config_id": ver_config_id}) 66 | check_and_raise_not_found_http_exception(ver_conf, NOT_FOUND_MSG) 67 | return bool(ver_conf) 68 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/examples.py: -------------------------------------------------------------------------------- 1 | ex_ver_config = { 2 | "ver_config_id": "test-request-config", 3 | "include_v1_attributes": False, 4 | "generate_consistent_identifier": False, 5 | "subject_identifier": "first_name", 6 | "proof_request": { 7 | "name": "Basic Proof", 8 | "version": "1.0", 9 | "requested_attributes": [ 10 | {"names": ["first_name", "last_name"], "restrictions": []}, 11 | ], 12 | "requested_predicates": [], 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/helpers.py: -------------------------------------------------------------------------------- 1 | from .variableSubstitutions import variable_substitution_map 2 | 3 | 4 | class VariableSubstitutionError(Exception): 5 | """Custom exception for if a variable is used that does not exist.""" 6 | 7 | def __init__(self, variable_name: str): 8 | self.variable_name = variable_name 9 | super().__init__(f"Variable '{variable_name}' not found in substitution map.") 10 | 11 | 12 | def replace_proof_variables(proof_req_dict: dict) -> dict: 13 | """ 14 | Recursively replaces variables in the proof request with actual values. 15 | The map is provided by imported variable_substitution_map. 16 | Additional variables can be added to the map in the variableSubstitutions.py file, 17 | or other dynamic functionality. 18 | 19 | Args: 20 | proof_req_dict (dict): The proof request dictionary from the resolved config. 21 | 22 | Returns: 23 | dict: The updated proof request dictionary with placeholder variables replaced. 24 | """ 25 | 26 | for k, v in proof_req_dict.items(): 27 | # If the value is a dictionary, recurse 28 | if isinstance(v, dict): 29 | replace_proof_variables(v) 30 | # If the value is a list, iterate through list items and recurse 31 | elif isinstance(v, list): 32 | for i in v: 33 | if isinstance(i, dict): 34 | replace_proof_variables(i) 35 | # If the value is a string and matches a key in the map, replace it 36 | elif isinstance(v, str) and v.startswith("$"): 37 | if v in variable_substitution_map: 38 | proof_req_dict[k] = variable_substitution_map[v]() 39 | else: 40 | raise VariableSubstitutionError(v) 41 | 42 | # Base case: If the value is not a dict, list, or matching string, do nothing 43 | return proof_req_dict 44 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/models.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pydantic import BaseModel, ConfigDict, Field 3 | 4 | from .examples import ex_ver_config 5 | from ..core.config import settings 6 | from .helpers import replace_proof_variables 7 | 8 | 9 | # Slightly modified from ACAPY models. 10 | class AttributeFilter(BaseModel): 11 | schema_id: str | None = None 12 | cred_def_id: str | None = None 13 | schema_name: str | None = None 14 | schema_issuer_did: str | None = None 15 | schema_version: str | None = None 16 | issuer_did: str | None = None 17 | 18 | 19 | class ReqAttr(BaseModel): 20 | names: list[str] 21 | label: str | None = None 22 | restrictions: list[AttributeFilter] 23 | 24 | 25 | class ReqPred(BaseModel): 26 | name: str 27 | label: str | None = None 28 | restrictions: list[AttributeFilter] 29 | p_value: int | str 30 | p_type: str 31 | 32 | 33 | class VerificationProofRequest(BaseModel): 34 | name: str | None = None 35 | version: str = Field(pattern="[0-9](.[0.9])*", examples=["0.0.1"]) 36 | non_revoked: str | None = None 37 | requested_attributes: list[ReqAttr] 38 | requested_predicates: list[ReqPred] 39 | 40 | 41 | class MetaData(BaseModel): 42 | title: str | None = Field(default=None) 43 | claims: list[str] | None = Field(default=None) 44 | 45 | 46 | class VerificationConfigBase(BaseModel): 47 | subject_identifier: str = Field() 48 | proof_request: VerificationProofRequest = Field() 49 | generate_consistent_identifier: bool | None = Field(default=False) 50 | include_v1_attributes: bool | None = Field(default=False) 51 | metadata: dict[str, MetaData] | None = Field(default=None) 52 | 53 | def get_now(self) -> int: 54 | return int(time.time()) 55 | 56 | def generate_proof_request(self): 57 | result = { 58 | "name": "proof_requested", 59 | "version": "0.0.1", 60 | "requested_attributes": {}, 61 | "requested_predicates": {}, 62 | } 63 | if self.proof_request.name: 64 | result["name"] = self.proof_request.name 65 | for i, req_attr in enumerate(self.proof_request.requested_attributes): 66 | label = req_attr.label or "req_attr_" + str(i) 67 | result["requested_attributes"][label] = req_attr.model_dump( 68 | exclude_none=True 69 | ) 70 | if settings.SET_NON_REVOKED: 71 | result["requested_attributes"][label]["non_revoked"] = { 72 | "from": int(time.time()), 73 | "to": int(time.time()), 74 | } 75 | for i, req_pred in enumerate(self.proof_request.requested_predicates): 76 | label = req_pred.label or "req_pred_" + str(i) 77 | result["requested_predicates"][label] = req_pred.model_dump( 78 | exclude_none=True 79 | ) 80 | if settings.SET_NON_REVOKED: 81 | result["requested_predicates"][label]["non_revoked"] = { 82 | "from": int(time.time()), 83 | "to": int(time.time()), 84 | } 85 | # Recursively check for subistitution variables and invoke replacement function 86 | result = replace_proof_variables(result) 87 | return result 88 | 89 | model_config = ConfigDict(json_schema_extra={"example": ex_ver_config}) 90 | 91 | 92 | class VerificationConfig(VerificationConfigBase): 93 | ver_config_id: str = Field() 94 | 95 | 96 | class VerificationConfigRead(VerificationConfigBase): 97 | ver_config_id: str = Field() 98 | 99 | 100 | class VerificationConfigPatch(VerificationConfigBase): 101 | subject_identifier: str | None = Field(None) 102 | proof_request: VerificationProofRequest | None = Field(None) 103 | 104 | pass 105 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/router.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import HTMLResponse 2 | from jinja2 import Template 3 | from pymongo.database import Database 4 | 5 | from fastapi import APIRouter, Depends 6 | from fastapi import status as http_status 7 | 8 | from .crud import VerificationConfigCRUD 9 | from .models import ( 10 | VerificationConfig, 11 | VerificationConfigPatch, 12 | VerificationConfigRead, 13 | ) 14 | from ..core.auth import get_api_key 15 | from ..core.models import GenericErrorMessage, StatusMessage 16 | from ..db.session import get_db 17 | from ..core.config import settings 18 | 19 | router = APIRouter() 20 | 21 | 22 | @router.post( 23 | "/", 24 | response_description="Add new verifier configuration", 25 | status_code=http_status.HTTP_201_CREATED, 26 | response_model=VerificationConfig, 27 | responses={http_status.HTTP_409_CONFLICT: {"model": GenericErrorMessage}}, 28 | response_model_exclude_unset=True, 29 | dependencies=[Depends(get_api_key)], 30 | ) 31 | async def create_ver_config( 32 | ver_config: VerificationConfig, db: Database = Depends(get_db) 33 | ): 34 | return await VerificationConfigCRUD(db).create(ver_config) 35 | 36 | 37 | @router.get( 38 | "/", 39 | status_code=http_status.HTTP_200_OK, 40 | response_model=list[VerificationConfigRead], 41 | response_model_exclude_unset=True, 42 | dependencies=[Depends(get_api_key)], 43 | ) 44 | async def get_all_ver_configs(db: Database = Depends(get_db)): 45 | return await VerificationConfigCRUD(db).get_all() 46 | 47 | 48 | @router.get("/explorer", include_in_schema=False) 49 | async def get_proof_request_explorer(db: Database = Depends(get_db)): 50 | data = { 51 | "title": "Presentation Request Explorer", 52 | } 53 | template_file = open( 54 | settings.CONTROLLER_TEMPLATE_DIR + "/ver_config_explorer.html", "r" 55 | ).read() 56 | template = Template(template_file) 57 | # get all from VerificationConfigCRUD and add to the jinja template 58 | ver_configs = await VerificationConfigCRUD(db).get_all() 59 | data["ver_configs"] = [vc.model_dump() for vc in ver_configs] 60 | 61 | return HTMLResponse(template.render(data)) 62 | 63 | 64 | @router.get( 65 | "/{ver_config_id}", 66 | status_code=http_status.HTTP_200_OK, 67 | response_model=VerificationConfigRead, 68 | responses={http_status.HTTP_404_NOT_FOUND: {"model": GenericErrorMessage}}, 69 | response_model_exclude_unset=True, 70 | dependencies=[Depends(get_api_key)], 71 | ) 72 | async def get_ver_conf(ver_config_id: str, db: Database = Depends(get_db)): 73 | return await VerificationConfigCRUD(db).get(ver_config_id) 74 | 75 | 76 | @router.patch( 77 | "/{ver_config_id}", 78 | status_code=http_status.HTTP_200_OK, 79 | response_model=VerificationConfigRead, 80 | responses={http_status.HTTP_404_NOT_FOUND: {"model": GenericErrorMessage}}, 81 | response_model_exclude_unset=True, 82 | dependencies=[Depends(get_api_key)], 83 | ) 84 | async def patch_ver_conf( 85 | ver_config_id: str, 86 | data: VerificationConfigPatch, 87 | db: Database = Depends(get_db), 88 | ): 89 | return await VerificationConfigCRUD(db).patch( 90 | ver_config_id=ver_config_id, data=data 91 | ) 92 | 93 | 94 | @router.delete( 95 | "/{ver_config_id}", 96 | status_code=http_status.HTTP_200_OK, 97 | response_model=StatusMessage, 98 | responses={http_status.HTTP_404_NOT_FOUND: {"model": GenericErrorMessage}}, 99 | dependencies=[Depends(get_api_key)], 100 | ) 101 | async def delete_ver_conf_by_uuid(ver_config_id: str, db: Database = Depends(get_db)): 102 | status = await VerificationConfigCRUD(db).delete(ver_config_id) 103 | return StatusMessage(status=status, message="The verifier config was deleted") 104 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import patch 3 | from api.verificationConfigs.helpers import ( 4 | replace_proof_variables, 5 | VariableSubstitutionError, 6 | ) 7 | 8 | # Mock variable substitution map 9 | mock_variable_substitution_map = {"$var1": lambda: "value1", "$var2": lambda: "value2"} 10 | 11 | 12 | @patch( 13 | "api.verificationConfigs.helpers.variable_substitution_map", 14 | mock_variable_substitution_map, 15 | ) 16 | def test_replace_proof_variables_empty_dict(): 17 | assert replace_proof_variables({}) == {} 18 | 19 | 20 | @patch( 21 | "api.verificationConfigs.helpers.variable_substitution_map", 22 | mock_variable_substitution_map, 23 | ) 24 | def test_replace_proof_variables_no_variables(): 25 | input_dict = {"key1": "value1", "key2": "value2"} 26 | assert replace_proof_variables(input_dict) == input_dict 27 | 28 | 29 | @patch( 30 | "api.verificationConfigs.helpers.variable_substitution_map", 31 | mock_variable_substitution_map, 32 | ) 33 | def test_replace_proof_variables_with_variables(): 34 | input_dict = {"key1": "$var1", "key2": "$var2"} 35 | expected_dict = {"key1": "value1", "key2": "value2"} 36 | assert replace_proof_variables(input_dict) == expected_dict 37 | 38 | 39 | @patch( 40 | "api.verificationConfigs.helpers.variable_substitution_map", 41 | mock_variable_substitution_map, 42 | ) 43 | def test_replace_proof_variables_variable_not_found(): 44 | input_dict = {"key1": "$var3"} 45 | with pytest.raises(VariableSubstitutionError): 46 | replace_proof_variables(input_dict) 47 | 48 | 49 | @patch( 50 | "api.verificationConfigs.helpers.variable_substitution_map", 51 | mock_variable_substitution_map, 52 | ) 53 | def test_replace_proof_variables_nested_dict(): 54 | input_dict = {"key1": {"key2": "$var1"}} 55 | expected_dict = {"key1": {"key2": "value1"}} 56 | assert replace_proof_variables(input_dict) == expected_dict 57 | 58 | 59 | @patch( 60 | "api.verificationConfigs.helpers.variable_substitution_map", 61 | mock_variable_substitution_map, 62 | ) 63 | def test_replace_proof_variables_list(): 64 | input_dict = {"key1": [{"key2": "$var2"}]} 65 | expected_dict = {"key1": [{"key2": "value2"}]} 66 | assert replace_proof_variables(input_dict) == expected_dict 67 | -------------------------------------------------------------------------------- /oidc-controller/api/verificationConfigs/tests/test_variable_substitutions.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing_extensions import Callable 3 | import pytest 4 | from datetime import datetime, timedelta 5 | from api.verificationConfigs.variableSubstitutions import VariableSubstitutionMap 6 | 7 | 8 | def test_get_now(): 9 | vsm = VariableSubstitutionMap() 10 | assert abs(vsm.get_now() - int(time.time())) < 2 # Allowing a small time difference 11 | 12 | 13 | def test_get_today_date(): 14 | vsm = VariableSubstitutionMap() 15 | assert vsm.get_today_date() == int(datetime.today().strftime("%Y%m%d")) 16 | 17 | 18 | def test_get_tomorrow_date(): 19 | vsm = VariableSubstitutionMap() 20 | assert vsm.get_tomorrow_date() == int( 21 | (datetime.today() + timedelta(days=1)).strftime("%Y%m%d") 22 | ) 23 | 24 | 25 | def test_get_threshold_years_date(): 26 | vsm = VariableSubstitutionMap() 27 | years = 5 28 | expected_date = ( 29 | datetime.today().replace(year=datetime.today().year - years).strftime("%Y%m%d") 30 | ) 31 | assert vsm.get_threshold_years_date(str(years)) == int(expected_date) 32 | 33 | 34 | def test_user_defined_func(): 35 | vsm = VariableSubstitutionMap() 36 | func: Callable[[int], int] = lambda x, y: int(x) + int(y) 37 | vsm.add_variable_substitution(r"\$years since (\d+) (\d+)", func) 38 | days = 10 39 | years = 22 40 | assert f"$years since {years} {days}" in vsm 41 | assert vsm[f"$years since {years} {days}"]() == years + days 42 | 43 | 44 | def test_contains_static_variable(): 45 | vsm = VariableSubstitutionMap() 46 | assert "$now" in vsm 47 | assert "$today_int" in vsm 48 | assert "$tomorrow_int" in vsm 49 | 50 | 51 | def test_contains_dynamic_variable(): 52 | vsm = VariableSubstitutionMap() 53 | assert "$threshold_years_5" in vsm 54 | 55 | 56 | def test_getitem_static_variable(): 57 | vsm = VariableSubstitutionMap() 58 | assert callable(vsm["$now"]) 59 | assert callable(vsm["$today_int"]) 60 | assert callable(vsm["$tomorrow_int"]) 61 | 62 | 63 | def test_getitem_dynamic_variable(): 64 | vsm = VariableSubstitutionMap() 65 | assert callable(vsm["$threshold_years_5"]) 66 | 67 | 68 | def test_getitem_key_error(): 69 | vsm = VariableSubstitutionMap() 70 | with pytest.raises(KeyError): 71 | vsm["$non_existent_key"] 72 | -------------------------------------------------------------------------------- /oidc-controller/conftest.py: -------------------------------------------------------------------------------- 1 | from api.core.config import settings 2 | 3 | import pytest 4 | import mongomock 5 | import logging 6 | 7 | # disable mongodb logging when running tests 8 | logging.getLogger("pymongo").setLevel(logging.CRITICAL) 9 | 10 | 11 | @pytest.fixture() 12 | def db_client(): 13 | def get_mock_db_client() -> mongomock.MongoClient: 14 | return mongomock.MongoClient() 15 | 16 | return get_mock_db_client 17 | 18 | 19 | @pytest.fixture() 20 | def db(db_client): 21 | return db_client().db 22 | 23 | 24 | settings.CONTROLLER_URL = "https://controller" 25 | settings.TESTING = True 26 | -------------------------------------------------------------------------------- /oidc-controller/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ $? == 0 ]; then 3 | exec uvicorn api.main:app --host 0.0.0.0 --port 5000 --log-level error --forwarded-allow-ips="*" 4 | fi 5 | exit 1 6 | -------------------------------------------------------------------------------- /oidc-controller/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = . 3 | asyncio_default_fixture_loop_scope = function 4 | -------------------------------------------------------------------------------- /oidc-controller/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup(name="traction", packages=find_packages()) 4 | -------------------------------------------------------------------------------- /oidc-controller/tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | per-file-ignores = 5 | */__mocks__.py: E501 6 | 7 | [tox] 8 | skipsdist = True 9 | envlist = py311 10 | 11 | [testenv] 12 | deps= 13 | -r requirements.txt 14 | -r requirements-dev.txt 15 | 16 | [testenv:lint] 17 | skip_install = false 18 | commands = 19 | flake8 api 20 | black api --diff --check 21 | 22 | [testenv:test] 23 | skip_install = false 24 | commands = 25 | pytest 26 | 27 | [testenv:coverage] 28 | skip_install = false 29 | commands = 30 | pytest --cov-config=.coveragerc --cov . --cov-report=html 31 | 32 | [gh-actions] 33 | python = 34 | 3.12: py312 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "acapy-vc-authn-oidc" 3 | version = "0.2.2" 4 | description = "Verifiable Credential Identity Provider for OpenID Connect." 5 | authors = [ { name = "Government of British Columbia", email = "DItrust@gov.bc.ca" } ] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | classifiers = [ 9 | "Programming Language :: Python :: 3", 10 | "License :: OSI Approved :: Apache Software License", 11 | "Operating System :: OS Independent", 12 | ] 13 | repository = "https://github.com/openwallet-foundation/acapy-vc-authn-oidc" 14 | 15 | [tool.poetry.dependencies] 16 | python = ">=3.12,<4.0" 17 | fastapi = "^0.115.12" 18 | jinja2 = "^3.1.6" 19 | oic = "^1.7.0" 20 | pymongo = "^4.12.1" 21 | pyop = "^3.4.1" 22 | python-multipart = "^0.0.20" # required by fastapi to serve/upload files 23 | qrcode = { version = "^8.2", extras = ["pil"]} 24 | structlog = "^25.3.0" 25 | uvicorn = { version = "^0.34.2", extras = ["standard"] } 26 | python-socketio = "^5.13.0" # required to run websockets 27 | canonicaljson = "^2.0.0" # used to provide unique consistent user identifiers" 28 | pydantic-settings = "^2.9.1" 29 | 30 | [tool.poetry.group.dev.dependencies] 31 | black = "^25.1.0" 32 | mock = "^5.2.0" 33 | mongomock = "^4.3.0" 34 | pytest-asyncio = "^0.26.0" 35 | pytest-cov = "^6.1.1" 36 | pytest = "^8.3.5" 37 | requests-mock = "^1.12.1" 38 | setuptools = "^80.3.1" 39 | 40 | [tool.pytest.ini_options] 41 | testpaths = "oidc-controller" 42 | asyncio_default_fixture_loop_scope = "function" 43 | 44 | [tool.pyright] 45 | pythonVersion = "3.12" 46 | 47 | [build-system] 48 | requires = ["poetry-core>=2.0"] 49 | build-backend = "poetry.core.masonry.api" 50 | --------------------------------------------------------------------------------